Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc9c052b51 | ||
|
|
db6e4f0654 | ||
|
|
3f20305eda | ||
|
|
868b4c9b39 | ||
|
|
327da58c09 | ||
|
|
b5c79a1eea | ||
|
|
68a971b8a1 | ||
|
|
160a852bdb | ||
|
|
7f5bbd8be8 | ||
|
|
0cb45084bc | ||
|
|
a40e0b7a47 | ||
|
|
58c45a9905 | ||
|
|
9f16295f77 | ||
|
|
8e53959bb9 | ||
|
|
d860a008a1 | ||
|
|
5d5397bf52 | ||
|
|
f89bc08876 | ||
|
|
97ba05cfe3 | ||
|
|
422edc2dda | ||
|
|
0a2937d766 | ||
|
|
7e4bd816d1 | ||
|
|
75eb86a93a | ||
|
|
80c37602c0 | ||
|
|
b286701535 | ||
|
|
c50434594c | ||
|
|
528d711f95 | ||
|
|
4fbdaf2c32 | ||
|
|
950713eef6 | ||
|
|
41e5c20191 | ||
|
|
f6b6a69197 | ||
|
|
0b00d78c98 | ||
|
|
5a8f0258d6 | ||
|
|
761ebf78bb | ||
|
|
7e45720dc8 | ||
|
|
0fc3a238d7 | ||
|
|
5024e90d68 | ||
|
|
f5ee33b216 | ||
|
|
4fd12318df | ||
|
|
bf71ae7168 | ||
|
|
4187521ba4 | ||
|
|
07f8789514 | ||
|
|
8e19e815cc | ||
|
|
f31db69e03 | ||
|
|
2ec4baec3f | ||
|
|
60a4610c6b | ||
|
|
fc89a1b67e | ||
|
|
f124bd2c84 | ||
|
|
df0e29ac8b | ||
|
|
692599aa94 | ||
|
|
ad67c1a121 | ||
|
|
4d60db05ba | ||
|
|
77eaa312e0 | ||
|
|
8c120bee34 | ||
|
|
140c380584 | ||
|
|
7d7dc78107 | ||
|
|
d057dee9c3 | ||
|
|
24ad98df8c | ||
|
|
d05bb11724 | ||
|
|
ef2261feb8 | ||
|
|
eab11969da | ||
|
|
d514787f95 | ||
|
|
676ee682ec | ||
|
|
ed77125ae3 | ||
|
|
4ceb880d19 | ||
|
|
fdbafe5420 | ||
|
|
13de7a6743 | ||
|
|
212051da35 | ||
|
|
52144704e7 | ||
|
|
ffa8ac142c | ||
|
|
9a8c87acfe | ||
|
|
6c5e1be3db | ||
|
|
b1537be5b5 | ||
|
|
c4da6d500b | ||
|
|
c9dc4c466d | ||
|
|
2a66bad497 | ||
|
|
be954671ca | ||
|
|
939a076baf | ||
|
|
19d2f9216d | ||
|
|
72a69e2476 | ||
|
|
051f0cc1ec | ||
|
|
d464c28e0a | ||
|
|
d51e9434f9 | ||
|
|
15833ab01a | ||
|
|
ce929a8241 | ||
|
|
9694e2cb57 | ||
|
|
73d4e43b22 | ||
|
|
e66cfff0c0 | ||
|
|
ab716d02a5 | ||
|
|
ed772a01f9 | ||
|
|
708ef4a9fd | ||
|
|
8ec438ef57 | ||
|
|
e46374d24b | ||
|
|
6ac2de8e04 | ||
|
|
ab669fd3b1 | ||
|
|
b130301bd5 | ||
|
|
ad9874fcd5 | ||
|
|
334fc6d0be | ||
|
|
1ed7f49f13 | ||
|
|
097687cf63 | ||
|
|
46e3b22316 | ||
|
|
bbe6ff9477 | ||
|
|
b1df41b943 | ||
|
|
e1fce46006 | ||
|
|
cace2230d8 | ||
|
|
04af6a436d | ||
|
|
e00bc666e7 | ||
|
|
593cfebd79 | ||
|
|
6434370034 | ||
|
|
82af1fff27 | ||
|
|
5289bdfedb | ||
|
|
694289c423 | ||
|
|
723e45bf2b | ||
|
|
37e34f6cb5 | ||
|
|
f88951e8d4 | ||
|
|
4bc1ea8078 | ||
|
|
dc7d0be10c | ||
|
|
15852edbaf | ||
|
|
eb3ff2e61a | ||
|
|
0225eb4a08 | ||
|
|
1dadd33f35 | ||
|
|
ec5d1eb1c7 | ||
|
|
9be2b97b6c | ||
|
|
8757ba2650 | ||
|
|
717a57ff43 | ||
|
|
4e0fce010e | ||
|
|
5ba8399bb2 | ||
|
|
ce46747282 | ||
|
|
02b8753d97 | ||
|
|
300e824e94 | ||
|
|
af2bd0e10a | ||
|
|
526d58ae93 | ||
|
|
4d3daf96d3 | ||
|
|
30b82ba95f | ||
|
|
6aa8e93bc9 | ||
|
|
0dd0ade3b9 | ||
|
|
b5f3915d5b | ||
|
|
e54896699b | ||
|
|
6dd99ec4b0 | ||
|
|
a329acf23c | ||
|
|
206cfbb6ca | ||
|
|
22804b8e08 | ||
|
|
c19d894f68 | ||
|
|
7b1f6247d0 | ||
|
|
b11f41484c | ||
|
|
10d1ce6a94 | ||
|
|
038a7e0e15 | ||
|
|
29db125b9b | ||
|
|
818e4f3b31 | ||
|
|
48f4eb6dfe | ||
|
|
9b7cfc9940 | ||
|
|
c420a9f66c | ||
|
|
df295bd743 | ||
|
|
387d50df27 | ||
|
|
55d4e379a7 | ||
|
|
13c38ac588 | ||
|
|
a0508cb065 | ||
|
|
2d4fae1ac3 | ||
|
|
b06cf2452e | ||
|
|
bf156fb010 | ||
|
|
df5db57e79 | ||
|
|
071fa015e0 | ||
|
|
15ac4303f5 | ||
|
|
6957c7e599 | ||
|
|
234343ef68 | ||
|
|
c4d8804b44 | ||
|
|
d88597cd72 | ||
|
|
753df201ac | ||
|
|
d0d011eb33 | ||
|
|
dbbf4c5af5 | ||
|
|
c9d8a40a37 | ||
|
|
be819da146 | ||
|
|
cbd1567b6a | ||
|
|
653e9774e8 | ||
|
|
5fe54df4c8 | ||
|
|
f51ecc1621 | ||
|
|
d12c96e43e |
@@ -0,0 +1,77 @@
|
||||
# MemPalace — Antigravity plugin
|
||||
|
||||
In-repo packaging for the MemPalace integration with Google's [Antigravity IDE](https://antigravity.google/).
|
||||
|
||||
This directory is the source of truth for what gets installed at
|
||||
`~/.gemini/config/plugins/mempalace/` when the user runs the installer.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.antigravity-plugin/
|
||||
├── plugin.json # marker manifest (verified minimal schema)
|
||||
├── mcp_config.json # auto-registers the mempalace-mcp stdio server
|
||||
├── hooks.json.tmpl # template — installer renders to hooks.json
|
||||
├── skills/
|
||||
│ ├── mempalace/
|
||||
│ │ └── SKILL.md # ops skill: setup, mine, status, CLI delegation
|
||||
│ └── mempalace-recall/
|
||||
│ └── SKILL.md # recall-only skill: search-before-answer protocol
|
||||
├── rules/
|
||||
│ └── mempalace-recall.md # optional recall rule (complements the skill)
|
||||
└── README.md # this file
|
||||
```
|
||||
|
||||
The hook scripts themselves live at `hooks/antigravity/`. The installer
|
||||
copies them into `<install-dir>/hooks/` and renders `hooks.json.tmpl`
|
||||
into a `hooks.json` whose `command` paths point at the absolute install
|
||||
location.
|
||||
|
||||
## Three recall layers
|
||||
|
||||
MemPalace can store everything, but it only helps if the agent actually
|
||||
*reads* the palace before answering. Three layers wire that in, from
|
||||
eager to on-demand:
|
||||
|
||||
1. **Wake hook** (`hooks/antigravity/mempal_wake_hook_antigravity.sh`,
|
||||
`PreInvocation` event, gated to `invocationNum == 1`). On the first
|
||||
model call of a conversation it runs `mempalace wake-up` and injects
|
||||
the **actual palace content verbatim** via Antigravity's
|
||||
`injectSteps[].ephemeralMessage` output. This is Antigravity's native
|
||||
equivalent of Cursor's `sessionStart` `additional_context`, except it
|
||||
delivers the memory itself rather than a directive to go fetch it.
|
||||
2. **Recall skill** (`skills/mempalace-recall/SKILL.md`). The
|
||||
search-before-answer protocol the agent follows when a turn is
|
||||
recall-relevant — tool selection, unhappy paths, anti-patterns. It
|
||||
covers recall only; the `mempalace` skill covers setup / mine /
|
||||
status.
|
||||
3. **Optional recall rule** (`rules/mempalace-recall.md`). A lightweight
|
||||
markdown rule that nudges the agent to search before answering when
|
||||
Antigravity's matcher decides the turn is recall-relevant. It is
|
||||
deliberately recall-scoped (not an always-on global rule) so it never
|
||||
adds latency to greenfield work, honouring MemPalace's "memory should
|
||||
feel instant" budget.
|
||||
|
||||
All three point to the single canonical protocol in
|
||||
[`integrations/shared/recall-protocol.md`](../integrations/shared/recall-protocol.md)
|
||||
so the skill and rule never drift.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh
|
||||
```
|
||||
|
||||
The installer is idempotent and the uninstaller matches by basename, so
|
||||
re-runs and partial installs are safe.
|
||||
|
||||
See [website/guide/antigravity.md](../website/guide/antigravity.md) for
|
||||
the full user-facing guide and [hooks/antigravity/README.md](../hooks/antigravity/README.md)
|
||||
for the hooks-specific documentation.
|
||||
|
||||
## Verified surfaces
|
||||
|
||||
Every file in this directory maps to a surface verified against
|
||||
[Google's Antigravity docs](https://antigravity.google/docs/). See
|
||||
[hooks/antigravity/INVESTIGATION.md](../hooks/antigravity/INVESTIGATION.md)
|
||||
for the full audit, including the surfaces deliberately not shipped.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"mempalace-save": {
|
||||
"Stop": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "__PLUGIN_DIR__/hooks/mempal_save_hook_antigravity.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
"mempalace-wake": {
|
||||
"PreInvocation": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "__PLUGIN_DIR__/hooks/mempal_wake_hook_antigravity.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"name": "mempalace"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# MemPalace recall
|
||||
|
||||
Before answering anything that may already be in the user's memory
|
||||
palace — past work, prior decisions, a person, a project, or "what did
|
||||
we do / decide / discuss last time?" — search the palace first:
|
||||
|
||||
1. Call `mempalace_search` with a short keyword query. Use
|
||||
`mempalace_kg_query` for relational or time-bound facts.
|
||||
2. Quote the drawer's **verbatim** text. Never summarize or paraphrase
|
||||
stored content.
|
||||
3. If results are empty, say so — do not invent an answer. If the MCP
|
||||
server is unavailable, surface the error; do not fall back to guessing.
|
||||
|
||||
Skip recall for pure greenfield work with no memory relevance (renaming
|
||||
a variable, fixing a typo). Recall is question-driven, not reflexive.
|
||||
|
||||
Full protocol: <https://github.com/MemPalace/mempalace/blob/main/integrations/shared/recall-protocol.md>. Deeper guidance:
|
||||
the `mempalace-recall` skill.
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: mempalace-recall
|
||||
description: "Recall protocol for MemPalace — search the palace before answering about past work, people, projects, or prior decisions. Apply when the user asks what was decided, what happened before, who someone is, what was discussed last time, or anything that may already be filed in their memory palace; or when mempalace-recall is invoked. Complements the mempalace setup skill and requires the mempalace-mcp server."
|
||||
---
|
||||
|
||||
# MemPalace Recall
|
||||
|
||||
Search-before-answer protocol for MemPalace. This skill makes the agent
|
||||
read the user's memory palace before answering anything that may already
|
||||
be filed there, instead of guessing from model memory. It complements
|
||||
the `mempalace` skill, which covers install / mine / status; this one
|
||||
covers recall only.
|
||||
|
||||
## Step 0 — Verify MemPalace is available
|
||||
|
||||
Before relying on recall, confirm MemPalace is installed and reachable:
|
||||
|
||||
- Official release page: <https://github.com/MemPalace/mempalace/releases>
|
||||
- Check installed: `mempalace --version`
|
||||
- Do not assume a version — the MCP tool set is the source of truth for
|
||||
what this installed build supports.
|
||||
|
||||
If the `mempalace_*` MCP tools are not available, tell the user the
|
||||
server is not connected and point them at the `mempalace` skill to set
|
||||
it up. Do not silently fall back to answering from model memory.
|
||||
|
||||
## Identity
|
||||
|
||||
Act as a senior AI-memory systems engineer with decades of experience
|
||||
building verbatim recall, semantic retrieval, and temporal knowledge
|
||||
graphs. Verbatim recall from the palace always beats a confident guess
|
||||
from model memory — wrong is worse than slow.
|
||||
|
||||
## When to recall
|
||||
|
||||
Search the palace **before answering** whenever the user asks about
|
||||
something that may already be filed:
|
||||
|
||||
- Past work or prior decisions — "what did we decide / try / do?"
|
||||
- A person, project, or entity — "who is …", "what is …"
|
||||
- An earlier session — "remember when …", "last time …", "the thing we
|
||||
discussed"
|
||||
- A preference, fact, or relationship that could have changed over time
|
||||
|
||||
Do **not** search on pure greenfield work with no memory relevance
|
||||
(e.g. "rename this variable", "fix this typo"). Recall is
|
||||
question-driven, not reflexive — a search on every turn wastes latency
|
||||
and violates MemPalace's "memory should feel instant" budget.
|
||||
|
||||
## Protocol
|
||||
|
||||
1. On wake-up, the MemPalace PreInvocation hook injects verbatim palace
|
||||
content via `injectSteps[].ephemeralMessage` on the first model call
|
||||
of a conversation. If memory was injected, start from it before
|
||||
searching further.
|
||||
2. **Before responding** about people, projects, past events, or prior
|
||||
decisions: call `mempalace_search` first. For relational or temporal
|
||||
facts ("who reported to whom in March", "what was true then"), call
|
||||
`mempalace_kg_query` instead or as well.
|
||||
3. **If unsure** about a fact (name, age, relationship, preference): say
|
||||
"let me check the palace" and query. Wrong is worse than slow.
|
||||
4. **Return verbatim.** Quote the drawer's exact stored words. Never
|
||||
summarize, paraphrase, or lossy-compress what the palace returns —
|
||||
that is the whole point of the system.
|
||||
5. **After a substantive session**, record continuity with
|
||||
`mempalace_diary_write` (background hooks may already do this — do not
|
||||
double-file).
|
||||
6. **When a fact changes**, call `mempalace_kg_invalidate` on the old
|
||||
fact, then `mempalace_kg_add` for the new one.
|
||||
|
||||
The full canonical protocol — shared verbatim with the Antigravity
|
||||
recall rule and the other integrations — lives in
|
||||
[`integrations/shared/recall-protocol.md`](https://github.com/MemPalace/mempalace/blob/main/integrations/shared/recall-protocol.md).
|
||||
|
||||
## Tool selection
|
||||
|
||||
| You need | Tool |
|
||||
|---|---|
|
||||
| Find any memory by meaning | `mempalace_search` (start here) |
|
||||
| Relational / time-bound facts about an entity | `mempalace_kg_query` |
|
||||
| The chronological story of an entity | `mempalace_kg_timeline` |
|
||||
| Recent session continuity | `mempalace_diary_read` |
|
||||
| Which wings / rooms exist (scope unknown) | `mempalace_list_wings`, `mempalace_list_rooms` |
|
||||
| Record this session | `mempalace_diary_write` |
|
||||
|
||||
`mempalace_search` takes a short natural-language `query` (keywords or a
|
||||
question — not a system prompt or pasted conversation) plus optional
|
||||
`wing` / `room` filters and `limit` (default 5).
|
||||
|
||||
## Unhappy paths
|
||||
|
||||
- **Empty results.** Say the palace has nothing on this; do not invent an
|
||||
answer to fill the gap. Offer to widen the search (drop the wing
|
||||
filter) or to file the new information.
|
||||
- **MCP unavailable / tool error.** Surface the error plainly and suggest
|
||||
the user verify the server (`mempalace status`, or re-run the
|
||||
installer `hooks/antigravity/install.sh`). Do not silently fall back
|
||||
to guessing from model memory.
|
||||
- **Stale or conflicting facts.** Prefer the knowledge graph's
|
||||
time-valid answer; if a fact has changed, invalidate the old one and
|
||||
add the new one rather than overwriting context silently.
|
||||
|
||||
## Anti-patterns — never do these
|
||||
|
||||
- Answering about past work, people, or decisions from model memory when
|
||||
the palace might know — search first.
|
||||
- Paraphrasing or summarizing stored content instead of quoting it
|
||||
verbatim.
|
||||
- Searching reflexively on every turn, including pure greenfield coding
|
||||
with no memory relevance.
|
||||
- Pasting the full conversation or a system prompt into the `query`
|
||||
argument — keep queries short and keyword-driven.
|
||||
|
||||
## Official References
|
||||
|
||||
- MemPalace: <https://github.com/MemPalace/mempalace>
|
||||
- MemPalace releases: <https://github.com/MemPalace/mempalace/releases>
|
||||
- Antigravity documentation: <https://antigravity.google/docs>
|
||||
- Agent Skills specification: <https://agentskills.io/specification>
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: mempalace
|
||||
description: MemPalace — mine projects and conversations into a searchable memory palace. Use when the user asks about MemPalace, memory palace, mining memories, searching memories, palace setup, wings, rooms, or drawers; or when they want to recall past work that may already be filed in their palace.
|
||||
---
|
||||
|
||||
# MemPalace
|
||||
|
||||
A searchable memory palace for AI — mine projects and conversations, then search them semantically. Verbatim storage, local-first, zero external API by default.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure `mempalace` is installed:
|
||||
|
||||
```bash
|
||||
mempalace --version
|
||||
```
|
||||
|
||||
If not installed (uv recommended):
|
||||
|
||||
```bash
|
||||
uv tool install mempalace # or: pip install mempalace
|
||||
```
|
||||
|
||||
## Dynamic, version-correct instructions
|
||||
|
||||
MemPalace exposes operation-specific instructions through the CLI so this skill stays accurate as MemPalace evolves. To get instructions for any operation:
|
||||
|
||||
```bash
|
||||
mempalace instructions <command>
|
||||
```
|
||||
|
||||
Always prefer the CLI output over what is written here when the two disagree — the CLI is the single source of truth for the installed version.
|
||||
|
||||
## Common operations
|
||||
|
||||
These are the five operations users ask for most often. Each one wraps a single MemPalace CLI subcommand. The `mempalace instructions <name>` form returns the full, version-correct guidance.
|
||||
|
||||
### `help` — discover what MemPalace can do
|
||||
|
||||
```bash
|
||||
mempalace instructions help
|
||||
```
|
||||
|
||||
Use when the user is new, unsure what's possible, or asks "what can you do".
|
||||
|
||||
### `init` — first-run setup of the palace
|
||||
|
||||
```bash
|
||||
mempalace instructions init
|
||||
```
|
||||
|
||||
Use when the user has just installed MemPalace, no palace exists yet, or the user explicitly asks to set up / configure / re-initialize their palace.
|
||||
|
||||
### `mine` — ingest a project or conversation directory
|
||||
|
||||
```bash
|
||||
mempalace instructions mine
|
||||
```
|
||||
|
||||
Use when the user wants to fold a project's files into their palace, or to ingest exported conversation transcripts into the palace as searchable memory.
|
||||
|
||||
### `search` — find verbatim memories by semantic query
|
||||
|
||||
```bash
|
||||
mempalace instructions search
|
||||
```
|
||||
|
||||
Use when the user wants to recall something from the past, find a previous decision, or rediscover code/notes/conversations they already wrote.
|
||||
|
||||
### `status` — what's in the palace right now
|
||||
|
||||
```bash
|
||||
mempalace instructions status
|
||||
```
|
||||
|
||||
Use when the user asks "what's in my palace", "how big is my palace", or wants a summary of wings, rooms, and drawer counts.
|
||||
|
||||
## MCP tools (preferred over CLI)
|
||||
|
||||
Inside Antigravity, the MemPalace MCP server registers a rich set of tools. Use these instead of shelling out to the CLI for live operations (search, diary writes, drawer adds, knowledge graph queries, palace status). The MCP tools always reflect the current palace state without spawning a subprocess.
|
||||
|
||||
The MCP server is auto-registered when this plugin is installed at `~/.gemini/config/plugins/mempalace/`. If the server does not appear in Antigravity's MCP store, run `mempalace-mcp --version` to verify the binary is on PATH, then restart Antigravity.
|
||||
|
||||
## Design principles (verbatim from the project)
|
||||
|
||||
- **Verbatim always** — never summarize, paraphrase, or lossy-compress user data.
|
||||
- **Local-first, zero external API by default** — extraction, embedding, and LLM-assisted refinement happen on the user's machine.
|
||||
- **Privacy by architecture** — the system never calls out to external services for core operations.
|
||||
- **Performance budgets** — hooks under 500ms; startup injection under 100ms.
|
||||
- **Background everything** — filing, indexing, and timestamps happen via hooks in the background; zero tokens spent on bookkeeping in the chat window.
|
||||
|
||||
If a request would violate any of these principles, refuse and explain — even if it would be technically convenient.
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemPalace Claude Code Plugin
|
||||
|
||||
A Claude Code plugin that gives your AI a persistent memory system. Mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and 5 guided skills.
|
||||
A Claude Code plugin that gives your AI a persistent memory system. Mine projects and conversations into a searchable palace backed by ChromaDB, with 33 MCP tools, auto-save hooks, and 5 guided skills.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -50,7 +50,7 @@ Set the `MEMPAL_DIR` environment variable to a directory path to automatically r
|
||||
|
||||
## MCP Server
|
||||
|
||||
The plugin automatically configures a local MCP server with 19 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything.
|
||||
The plugin automatically configures a local MCP server with 33 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything.
|
||||
|
||||
## Full Documentation
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
{
|
||||
"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.3.6",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 33 MCP tools, auto-save hooks, guided setup.",
|
||||
"version": "3.4.1",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.3.6",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"version": "3.4.1",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 33 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: mempalace-recall
|
||||
description: Recall protocol for MemPalace — search the palace before answering about past work, prior decisions, people, or projects. Use when the user asks what was decided, what happened before, who someone is, what was discussed last time, or anything that may already be filed in their memory palace.
|
||||
allowed-tools: Bash
|
||||
---
|
||||
|
||||
# MemPalace Recall
|
||||
|
||||
Search-before-answer protocol for MemPalace. Read the user's memory
|
||||
palace before answering anything that may already be filed there,
|
||||
instead of guessing from model memory. This complements the `mempalace`
|
||||
skill (install / mine / status); this one covers recall only.
|
||||
|
||||
## Step 0 — Verify MemPalace is available
|
||||
|
||||
```bash
|
||||
mempalace --version
|
||||
```
|
||||
|
||||
If the `mempalace_*` MCP tools are not available, tell the user the
|
||||
server is not connected and point them at the `mempalace` skill or
|
||||
`/init`. Do not silently fall back to answering from model memory.
|
||||
|
||||
## When to recall
|
||||
|
||||
Search the palace **before answering** whenever the user asks about
|
||||
something that may be filed:
|
||||
|
||||
- Past work or prior decisions — "what did we decide / try / do?"
|
||||
- A person, project, or entity — "who is …", "what is …"
|
||||
- An earlier session — "remember when …", "last time …"
|
||||
- A preference, fact, or relationship that could have changed over time
|
||||
|
||||
Skip recall for pure greenfield work with no memory relevance (renaming
|
||||
a variable, fixing a typo). Recall is question-driven, not reflexive.
|
||||
|
||||
## Protocol
|
||||
|
||||
1. Before responding about people / projects / past events / prior
|
||||
decisions: call `mempalace_search` first. Use `mempalace_kg_query`
|
||||
for relational or time-bound facts.
|
||||
2. If unsure about a fact: say "let me check the palace" and query.
|
||||
3. Return the drawer's **verbatim** text — never summarize or paraphrase
|
||||
stored content.
|
||||
4. After a substantive session, record continuity with
|
||||
`mempalace_diary_write` (skip if a background hook already saved).
|
||||
5. When a fact changes: `mempalace_kg_invalidate` the old fact, then
|
||||
`mempalace_kg_add` the new one.
|
||||
|
||||
## Unhappy paths
|
||||
|
||||
- **Empty results** — say the palace has nothing on this; do not invent
|
||||
an answer. Offer to widen the search or file the new information.
|
||||
- **MCP error / server down** — surface the error, suggest `mempalace
|
||||
status` or re-running `/init`; never fall back to guessing.
|
||||
- **Conflicting facts** — trust the knowledge graph's time-valid answer;
|
||||
invalidate-then-add rather than overwriting silently.
|
||||
|
||||
The canonical protocol, shared across all MemPalace integrations, lives
|
||||
in `integrations/shared/recall-protocol.md`.
|
||||
@@ -1,6 +1,6 @@
|
||||
# MemPalace - Codex CLI Plugin
|
||||
|
||||
Give your AI a persistent memory -- mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and guided skills.
|
||||
Give your AI a persistent memory -- mine projects and conversations into a searchable palace backed by ChromaDB, with 33 MCP tools, auto-save hooks, and guided skills.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"version": "3.3.6",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
|
||||
"version": "3.4.1",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 33 MCP tools, auto-save hooks, and guided setup.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
@@ -27,7 +27,7 @@
|
||||
"interface": {
|
||||
"displayName": "MemPalace",
|
||||
"shortDescription": "AI memory system for Codex",
|
||||
"longDescription": "Give your AI a persistent memory — mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and guided skills.",
|
||||
"longDescription": "Give your AI a persistent memory — mine projects and conversations into a searchable palace backed by ChromaDB, with 33 MCP tools, auto-save hooks, and guided skills.",
|
||||
"developerName": "milla-jovovich",
|
||||
"category": "Coding",
|
||||
"capabilities": [
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# MemPalace Cursor Plugin
|
||||
|
||||
A Cursor IDE plugin that gives your agent a persistent memory system. Auto-registers the `mempalace-mcp` server (33 MCP tools), ships 5 slash commands, two model-invocable skills (setup/mining/search and a recall protocol), and an optional recall rule.
|
||||
|
||||
> Hooks (auto-save + session-start memory recall) are shipped separately under `hooks/cursor/` so the plugin is safe to install in any Cursor workspace without touching the agent loop. See [Hooks](#hooks-optional) below.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9+
|
||||
- Cursor 1.7+ (plugin manifest schema requires it)
|
||||
|
||||
## Installation
|
||||
|
||||
### Local clone (recommended while not in the marketplace yet)
|
||||
|
||||
Symlink (or copy) this repository into Cursor's local plugins folder:
|
||||
|
||||
```bash
|
||||
ln -s /path/to/mempalace ~/.cursor/plugins/local/mempalace
|
||||
```
|
||||
|
||||
Then in Cursor: <kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
|
||||
|
||||
### Marketplace
|
||||
|
||||
Once published, install via the Cursor marketplace panel and select `mempalace`. Required-plugin distribution from a team marketplace is also supported.
|
||||
|
||||
## Post-Install Setup
|
||||
|
||||
After installing the plugin, run the `init` command in a Cursor chat:
|
||||
|
||||
```
|
||||
/mempalace-init
|
||||
```
|
||||
|
||||
(Or just say "use the mempalace skill" — Cursor will model-invoke the bundled skill.)
|
||||
|
||||
This installs the `mempalace` package via `uv tool` or `pip`, initializes a palace under `~/.mempalace/`, and verifies the MCP server is reachable.
|
||||
|
||||
## Available Slash Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------------------|-----------------------------------------------------------------------------------|
|
||||
| `/mempalace-help` | Show available tools, skills, CLI commands, hooks, and architecture |
|
||||
| `/mempalace-init` | Set up MemPalace — install, configure, onboard |
|
||||
| `/mempalace-search` | Search your memories across the palace using semantic search |
|
||||
| `/mempalace-mine` | Mine projects and conversations into the palace |
|
||||
| `/mempalace-status` | Show palace overview — wings, rooms, drawer counts |
|
||||
|
||||
> Cursor commands are global, not plugin-namespaced — that's why each slug is prefixed with `mempalace-` rather than appearing as `/help`, `/init`, etc. This keeps them collision-free with built-in or other-plugin commands.
|
||||
|
||||
## Skills
|
||||
|
||||
Two model-invocable skills ship at the plugin root under `skills/`:
|
||||
|
||||
| Skill | What it does |
|
||||
|-------|--------------|
|
||||
| `mempalace` | Setup, mining, status, and the dynamic `mempalace instructions` CLI. |
|
||||
| `mempalace-recall` | Search-before-answer protocol — makes the agent read the palace before answering about past work, people, projects, or prior decisions instead of guessing. |
|
||||
|
||||
Cursor surfaces these automatically when a request matches their description, or you can attach them explicitly.
|
||||
|
||||
## Recall rule (optional)
|
||||
|
||||
The plugin also ships a Cursor rule at the plugin root under `rules/mempalace-recall.mdc`:
|
||||
|
||||
```yaml
|
||||
description: When the user asks about past work, prior decisions, people, ... call mempalace_search before answering ...
|
||||
alwaysApply: false
|
||||
```
|
||||
|
||||
It is `alwaysApply: false` on purpose — Cursor loads it only when its matcher judges the turn recall-relevant, so it never fires on unrelated coding work and never adds MCP latency to greenfield tasks. The rule, the `mempalace-recall` skill, and the `sessionStart` hook all reference the same canonical protocol in [`integrations/shared/recall-protocol.md`](../integrations/shared/recall-protocol.md).
|
||||
|
||||
Want recall forced into **every** conversation regardless of context? Copy the aggressive `alwaysApply: true` variant from [`examples/cursor/rules/`](../examples/cursor/rules/README.md) into `~/.cursor/rules/`. That is a deliberate, heavier opt-in, not a default.
|
||||
|
||||
## MCP Server
|
||||
|
||||
This plugin ships `mcp.json` at the plugin root, so Cursor auto-loads the `mempalace-mcp` server on plugin install:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All 33 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, …) become available to the agent immediately. No manual `~/.cursor/mcp.json` edit required.
|
||||
|
||||
If the server doesn't appear, confirm `mempalace-mcp` is on the user `$PATH`:
|
||||
|
||||
```bash
|
||||
command -v mempalace-mcp
|
||||
```
|
||||
|
||||
If it isn't, run `/init` (or `mempalace install` from a terminal) — `mempalace-mcp` is installed alongside the `mempalace` package.
|
||||
|
||||
## Hooks (optional)
|
||||
|
||||
Cursor's hooks system is configured separately from plugins (in `~/.cursor/hooks.json` or `.cursor/hooks.json`), so this plugin does **not** wire hooks itself. The MemPalace repository ships three Cursor-native hooks under [`hooks/cursor/`](../hooks/cursor/) that you install with one command.
|
||||
|
||||
User scope — writes `~/.cursor/hooks.json`, applies to every Cursor workspace (recommended):
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope user --variant full
|
||||
```
|
||||
|
||||
Project scope — writes `.cursor/hooks.json` under the current project only:
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope project --variant full
|
||||
```
|
||||
|
||||
What you get:
|
||||
|
||||
| Hook event | What it does |
|
||||
|----------------|-------------------------------------------------------------------------------------------------------|
|
||||
| `sessionStart` | Injects an `additional_context` recap of relevant memories scoped to the workspace wing |
|
||||
| `stop` | Counts agent turns; every N turns, emits a `followup_message` instructing a memory checkpoint |
|
||||
| `preCompact` | Synchronously mines the transcript before compaction, drops a marker so the next `stop` saves a diary |
|
||||
|
||||
Full details: [`website/guide/cursor-hooks.md`](../website/guide/cursor-hooks.md) and [`hooks/cursor/README.md`](../hooks/cursor/README.md).
|
||||
|
||||
## Uninstall
|
||||
|
||||
Remove the local plugin symlink:
|
||||
|
||||
```bash
|
||||
rm ~/.cursor/plugins/local/mempalace
|
||||
```
|
||||
|
||||
Then in Cursor: <kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
|
||||
|
||||
If you also installed the hooks, remove them (leaves any unrelated hooks in `hooks.json` untouched):
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope user --uninstall
|
||||
```
|
||||
|
||||
## Full Documentation
|
||||
|
||||
See the main [README](../README.md) for complete documentation, architecture details, and advanced usage.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"owner": {
|
||||
"name": "milla-jovovich",
|
||||
"url": "https://github.com/MemPalace"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mempalace",
|
||||
"source": ".",
|
||||
"description": "AI memory system — mine projects and conversations into a searchable palace. 33 MCP tools, slash commands, and a guided skill for Cursor.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "mempalace",
|
||||
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 33 MCP tools, slash commands, and a guided skill for Cursor.",
|
||||
"author": {
|
||||
"name": "milla-jovovich"
|
||||
},
|
||||
"homepage": "https://github.com/MemPalace/mempalace",
|
||||
"repository": "https://github.com/MemPalace/mempalace",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"memory",
|
||||
"ai",
|
||||
"rag",
|
||||
"mcp",
|
||||
"chromadb",
|
||||
"palace",
|
||||
"search"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Keep the build context lean — only what `uv sync` + the package need.
|
||||
|
||||
# VCS
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Python build artifacts / caches
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
coverage.xml
|
||||
|
||||
# Virtual environments (rebuilt inside the image)
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Local config / secrets
|
||||
.env
|
||||
.env.*
|
||||
.envrc
|
||||
mempal.yaml
|
||||
|
||||
# Editor / OS cruft
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Repo material not needed at runtime
|
||||
.devcontainer/
|
||||
.github/
|
||||
.agents/
|
||||
.claude/
|
||||
.codex/
|
||||
.codex-plugin/
|
||||
.claude-plugin/
|
||||
benchmarks/
|
||||
docs/
|
||||
website/
|
||||
landing/
|
||||
assets/
|
||||
examples/
|
||||
tests/
|
||||
*.md
|
||||
!README.md
|
||||
@@ -0,0 +1,95 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
# `main` is the release branch: pushes here publish the released image and
|
||||
# update `latest`; `v*` tags publish versioned images. develop does not
|
||||
# publish — it is validated via the pull_request trigger below.
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Needed for the emulated linux/arm64 build on real pushes.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Only authenticate + push for in-repo events. Fork PRs lack the
|
||||
# packages:write token, so they build (to validate the Dockerfile) but
|
||||
# do not push.
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# latest -> main (the latest release); semver tags -> released versions.
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
# Publish multi-arch (amd64 + arm64 for Apple Silicon / ARM hosts) on
|
||||
# real pushes; keep PRs amd64-only so the emulated arm64 build does not
|
||||
# slow the PR check.
|
||||
platforms: ${{ github.event_name != 'pull_request' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
# Fork PRs get a read-only Actions cache, so writing it just emits 403
|
||||
# noise — only export cache on in-repo events.
|
||||
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max' || '' }}
|
||||
|
||||
# Build-only validation for the CUDA image so it cannot silently rot (CUDA base
|
||||
# tag drift, cross-stage interpreter/venv copy paths, the `gpu` extra). The
|
||||
# runner has no GPU, so this only proves the image *compiles*; it is never
|
||||
# published (users build it themselves, per the README).
|
||||
build-gpu:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build GPU image (validation only — not published)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.gpu
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
cache-from: type=gha,scope=gpu
|
||||
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=gpu' || '' }}
|
||||
@@ -0,0 +1,124 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
# Publishes mempalace to PyPI via Trusted Publishing (OIDC — no stored token).
|
||||
#
|
||||
# Triggers:
|
||||
# * release: published — the normal path (drafting + publishing a GitHub
|
||||
# Release fires this).
|
||||
# * workflow_dispatch — a manual escape hatch. GitHub does not reliably emit
|
||||
# a `release` event when a *draft* tied to a pre-existing tag is published,
|
||||
# so this lets a maintainer run the publish for any existing tag from the
|
||||
# Actions tab ("Run workflow" → enter the tag, e.g. v3.4.0).
|
||||
#
|
||||
# Build + publish run in ONE job on purpose: a two-job split hands the wheel
|
||||
# between jobs via upload/download-artifact, which failed repeatedly with
|
||||
# `BlobNotFound` on the same-run download. Building and publishing in the same
|
||||
# job removes that handoff entirely. The job is gated by the `pypi` environment
|
||||
# (manual approval), and the checks below keep it self-contained — it never
|
||||
# uploads a tag that isn't on main or doesn't match the version manifest.
|
||||
#
|
||||
# One-time setup required before the first run (see docs/RELEASING.md):
|
||||
# 1. PyPI → Manage project `mempalace` → Publishing → Add a trusted publisher:
|
||||
# Owner: MemPalace Repository: mempalace
|
||||
# Workflow: publish.yml Environment: pypi
|
||||
# 2. GitHub → repo Settings → Environments → New environment `pypi`,
|
||||
# add yourself as a Required reviewer (this is the manual approval gate).
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing tag to build and publish (e.g. v3.4.0). Used when the release event didn't fire."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build + publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
# The `pypi` environment gate (required reviewer) pauses the whole job until
|
||||
# approved, and scopes the OIDC trust on the PyPI side.
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/mempalace
|
||||
permissions:
|
||||
contents: read # checkout
|
||||
id-token: write # mint the short-lived OIDC token for Trusted Publishing
|
||||
steps:
|
||||
- name: Resolve the release tag
|
||||
id: tag
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${RELEASE_TAG:-${INPUT_TAG:-}}"
|
||||
if [[ -z "$tag" ]]; then
|
||||
echo "::error::no tag to publish — neither a release tag nor a workflow_dispatch input was provided"
|
||||
exit 1
|
||||
fi
|
||||
# Validate the shape before it is ever used as a git ref (ref-injection
|
||||
# guard): require vMAJOR.MINOR.PATCH with an optional -/+ suffix, so
|
||||
# loose values like 'v3' or 'v3foo' are rejected.
|
||||
re='^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'
|
||||
if [[ ! "$tag" =~ $re ]]; then
|
||||
echo "::error::tag '$tag' is not a valid release tag (expected vMAJOR.MINOR.PATCH[-suffix])"
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved tag: $tag"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Fully-qualified refs/tags/ so an unqualified name can't resolve to a
|
||||
# same-named *branch* instead of the tag (checkout prefers branches).
|
||||
# steps.tag.outputs.tag is format-validated above (ref-injection guard).
|
||||
ref: refs/tags/${{ steps.tag.outputs.tag }}
|
||||
fetch-depth: 0 # full history for the ancestry check
|
||||
|
||||
- name: Verify the tag is on main
|
||||
env:
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin main
|
||||
tag_sha=$(git rev-parse HEAD) # the checked-out tag commit
|
||||
main_sha=$(git rev-parse FETCH_HEAD)
|
||||
if git merge-base --is-ancestor "$tag_sha" "$main_sha"; then
|
||||
echo "Tag $TAG ($tag_sha) is reachable from main — OK."
|
||||
else
|
||||
echo "::error::tag $TAG is not an ancestor of main. Releases must be cut from main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify the tag matches the version manifest
|
||||
env:
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# version.py is the source of truth (per CLAUDE.md); version-guard.yml
|
||||
# separately enforces that pyproject + the three plugin manifests agree.
|
||||
py_version=$(grep -E '^__version__' mempalace/version.py | cut -d'"' -f2)
|
||||
tag_version="${TAG#v}"
|
||||
if [[ "$tag_version" != "$py_version" ]]; then
|
||||
echo "::error::tag $TAG does not match mempalace/version.py ($py_version). Bump the version files before releasing."
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag $TAG matches manifest version $py_version — OK."
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Build sdist + wheel
|
||||
run: |
|
||||
python -m pip install --upgrade build
|
||||
python -m build
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
+32
-1
@@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.4.1] — 2026-06-14
|
||||
|
||||
### Features
|
||||
|
||||
- **Cursor IDE plugin (`.cursor-plugin/`).** Drops into `~/.cursor/plugins/local/mempalace` (or installs from the Cursor marketplace once published) and auto-registers the `mempalace-mcp` server, five slash commands (`/mempalace-help`, `/mempalace-init`, `/mempalace-mine`, `/mempalace-search`, `/mempalace-status`), and the model-invocable [`mempalace` skill](skills/mempalace/SKILL.md) — no manual `~/.cursor/mcp.json` edit required. The plugin manifest deliberately omits a hardcoded `version` field — `mempalace/version.py` is the single source of truth, so there is nothing to drift on the next release (a contract test enforces the field stays absent). The canonical plugin components (`commands/`, `skills/`, `mcp.json`) are real files at the plugin root; no symlinks are committed (committed symlinks materialise as broken text files on Windows clones with `core.symlinks=false`). Mirrors the surface of [`.claude-plugin/`](.claude-plugin/) and [`.codex-plugin/`](.codex-plugin/) without duplicating their hook scripts: the Cursor hook scripts under [`hooks/cursor/`](hooks/cursor/) (shipped in the same release) remain the canonical install path for `stop` / `preCompact` / `sessionStart`, wired separately by [`hooks/cursor/install.sh`](hooks/cursor/install.sh). Contract tests in [`tests/test_cursor_plugin_manifest.py`](tests/test_cursor_plugin_manifest.py) cover manifest JSON validity, kebab-case naming, `..`-free relative paths, on-disk path resolution, marketplace alignment, MCP config shape (`mcpServers` wrapper required by Cursor, unlike Claude's flat `.mcp.json`), the version-field-absent guard, the no-symlink guard, and every skill/command frontmatter — all pure file inspection so they run on any CI platform without Cursor itself.
|
||||
|
||||
- **Cursor IDE hook support (`stop` / `preCompact` / `sessionStart`).** Three new bash hooks live under [`hooks/cursor/`](hooks/cursor/) and share a `lib/common.sh` helpers module. The save hook counts `stop` invocations per Cursor `conversation_id` and emits a `followup_message` every `MEMPAL_SAVE_INTERVAL` (default 15) so the agent files the session into MemPalace and writes a diary entry. Unlike the silent-by-default Claude Code hook, the Cursor followup fires **on by default**: Cursor's transcript format is undocumented and `normalize.py` has no Cursor parser yet, so the background `mempalace mine --mode convos` is best-effort only and the `followup_message` is the load-bearing verbatim-capture path. Users who want the Claude-style "zero tokens in the chat window" behaviour can suppress it with `MEMPAL_CURSOR_SILENT=1` (or `MEMPAL_VERBOSE=false`); the default flips to silent once a Cursor transcript parser lands. The precompact hook synchronously mines the transcript before Cursor's compaction summarises it and drops a marker so the next `stop` forces a save nudge (Cursor's `preCompact` is observational-only — it cannot block or emit a `followup_message`, unlike Claude Code's `PreCompact`); the synchronous mine is bounded by Cursor's per-hook timeout, and because `mempalace mine` is incremental/append-only a killed mine resumes cleanly on the next run rather than corrupting the palace. The wake hook is Cursor-only: `sessionStart` returns `additional_context` telling the agent to recall scoped to the wing inferred from the workspace root. Honours the same `MEMPALACE_HOOKS_AUTO_SAVE=false` kill switch as the Claude Code hooks, plus a new `MEMPAL_DISABLE_HOOK=1` alias and a `MEMPAL_STATE_DIR` env override. Per-conversation state files are garbage-collected by a daily-throttled, Cursor-namespaced TTL sweep (`MEMPAL_STATE_TTL_DAYS`, default 30) so `cursor_*.count` / `cursor_*.pending` cannot grow unbounded — shared logs and other editors' state are never touched. Includes an opt-in installer at [`hooks/cursor/install.sh`](hooks/cursor/install.sh) with `--scope user|project`, `--variant full|minimal`, `--dry-run`, and `--uninstall` (idempotent, preserves unrelated hooks via `python3`-based JSON merge — no `jq` dependency). Example wirings live at [`examples/cursor/hooks.json`](examples/cursor/hooks.json) and [`examples/cursor/hooks.minimal.json`](examples/cursor/hooks.minimal.json); they are intentionally not placed at the repo root because Cursor auto-loads project hooks from any trusted workspace and we do not arm hooks on contributor checkout. Per-event stdin/stdout schema documented at [`hooks/cursor/STDIN_SHAPE.md`](hooks/cursor/STDIN_SHAPE.md). Walkthrough at [`website/guide/cursor-hooks.md`](website/guide/cursor-hooks.md). Coverage added in [`tests/test_cursor_hooks_shell.py`](tests/test_cursor_hooks_shell.py) and [`tests/test_cursor_hooks_install.py`](tests/test_cursor_hooks_install.py).
|
||||
|
||||
- **First-class Antigravity IDE support.** New `.antigravity-plugin/` package + idempotent installer at `hooks/antigravity/install.sh` that registers MemPalace as a Google Antigravity plugin (MCP server, skill, two lifecycle hooks) at `~/.gemini/config/plugins/mempalace/`. The Stop hook background-mines the active conversation transcript every Nth fire (default 15, configurable via `MEMPAL_SAVE_INTERVAL`); the PreInvocation hook injects verbatim memory on the first model call only via Antigravity's `injectSteps[].ephemeralMessage` output, gated by `invocationNum == 1`. Both hooks are bash 3.2.57 compatible (macOS default), use the same `~/.mempalace/hook_state/` directory as the Claude Code / Codex / Cursor hooks (`antigravity_*`-namespaced state files), and respect every existing kill switch (`MEMPAL_DISABLE_HOOK`, `MEMPALACE_HOOKS_AUTO_SAVE`, `~/.mempalace/config.json` `hooks.auto_save`). Installer is `cmp`-gated (re-run produces a byte-identical install), uninstall is basename-guarded (refuses to wipe a directory whose basename isn't `mempalace`), and `--dry-run` is side-effect free. Full audit of which Antigravity surfaces we ship and which we deliberately don't is in [`hooks/antigravity/INVESTIGATION.md`](hooks/antigravity/INVESTIGATION.md). User-facing guide: [`website/guide/antigravity.md`](website/guide/antigravity.md). Standalone examples in [`examples/antigravity/`](examples/antigravity/).
|
||||
- **Zero-config interpreter resolution.** `mempal_resolve_python` now derives the Python interpreter from the `mempalace-mcp` / `mempalace` console-script shebang on `$PATH` before falling back to `python3`. The common `uv tool install mempalace` / `pipx install` layout installs the console scripts into an isolated environment whose interpreter is **not** system `python3`, so the previous `command -v python3` resolution landed on a Python that couldn't import `mempalace`, the `-m mempalace` probe failed, and mining silently never fired. Resolution is pure shebang parsing + `stat` (no Python subprocess at source time, preserving the hook performance budget). `MEMPAL_PYTHON` remains the explicit override. Documented under *How the hooks find your `mempalace` install* in the guide.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **`embeddinggemma` no longer OOM-kills bulk re-embeds.** `EmbeddinggemmaONNX.__call__` ran a single `session.run` over its entire input, so a repair-scale batch (5000 docs) allocated attention buffers far beyond available RAM and the kernel killed the process silently: `mempalace repair --yes` on an `embedding_model: embeddinggemma` palace died right after `Building temporary collection:` with no traceback and no crash report (#1770). Embedding now runs in sub-batches of 32 docs (constructor-tunable `batch_size`), matching the internal batching of ChromaDB's bundled MiniLM embedder. Per-document vectors are unchanged: the model's pooled output is attention-masked, so sub-batch padding does not affect values. The embedder is also hardened for shared use: the process-wide EF cache and the lazy model load are thread-safe (concurrent first calls build exactly one ONNX session), and `__call__` handles a bare string, `None`, and empty input without triggering the model download.
|
||||
- **Backup retention to prevent unbounded disk usage.** `mempalace migrate` (full-palace `<palace>.pre-migrate.<timestamp>` copies) and `mempalace repair max-seq-id` (`chroma.sqlite3.max-seq-id-backup-<timestamp>` copies) each wrote a fresh, full-size, timestamped backup every run and never deleted the old ones. On a machine that mines or repairs on a schedule, those copies could silently accumulate until they filled the disk — one palace was found with hundreds of GB of stale backups beside a few hundred MB of live data, hidden from a normal `du` of the home directory. A new `max_backups` setting (default `10`, env `MEMPALACE_MAX_BACKUPS`, or `config.json`) now prunes the oldest backups after each new one is written. Set it to `0` to keep every backup. Pruning is keyed by filesystem mtime, scoped strictly to each backup's own naming pattern (live data is never touched), and best-effort so a deletion failure can never abort a migration or repair that already succeeded.
|
||||
|
||||
---
|
||||
|
||||
## [3.3.6] — 2026-05-24
|
||||
|
||||
### Features
|
||||
@@ -494,7 +516,16 @@ Initial public release.
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.2.0...HEAD
|
||||
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.4.1...HEAD
|
||||
[3.4.1]: https://github.com/MemPalace/mempalace/compare/v3.4.0...v3.4.1
|
||||
[3.4.0]: https://github.com/MemPalace/mempalace/compare/v3.3.6...v3.4.0
|
||||
[3.3.6]: https://github.com/MemPalace/mempalace/compare/v3.3.5...v3.3.6
|
||||
[3.3.5]: https://github.com/MemPalace/mempalace/compare/v3.3.4...v3.3.5
|
||||
[3.3.4]: https://github.com/MemPalace/mempalace/compare/v3.3.3...v3.3.4
|
||||
[3.3.3]: https://github.com/MemPalace/mempalace/compare/v3.3.2...v3.3.3
|
||||
[3.3.2]: https://github.com/MemPalace/mempalace/compare/v3.3.1...v3.3.2
|
||||
[3.3.1]: https://github.com/MemPalace/mempalace/compare/v3.3.0...v3.3.1
|
||||
[3.3.0]: https://github.com/MemPalace/mempalace/compare/v3.2.0...v3.3.0
|
||||
[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
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# MemPalace — CPU image.
|
||||
#
|
||||
# Multi-stage build using uv (the project ships a uv.lock, so we install from
|
||||
# the frozen lockfile for reproducible images). The default runtime is the MCP
|
||||
# server over stdio; the CLI is reachable through the same entrypoint.
|
||||
#
|
||||
# Build:
|
||||
# docker build -t mempalace .
|
||||
# docker build -t mempalace --build-arg EXTRAS="extract,spellcheck" .
|
||||
#
|
||||
# Run (MCP server over stdio, palace persisted on the host):
|
||||
# docker run -i --rm -v mempalace-data:/data mempalace
|
||||
#
|
||||
# Run (CLI):
|
||||
# docker run --rm -v mempalace-data:/data mempalace search "why GraphQL"
|
||||
#
|
||||
# GPU acceleration lives in Dockerfile.gpu (it needs a CUDA base image).
|
||||
|
||||
ARG PYTHON_VERSION=3.12
|
||||
|
||||
# --- builder ----------------------------------------------------------------
|
||||
FROM python:${PYTHON_VERSION}-slim AS builder
|
||||
|
||||
# uv: fast, lockfile-driven installer. Pinned by digest-less tag for clarity;
|
||||
# bump deliberately.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /uvx /bin/
|
||||
|
||||
# Some transitive deps (grpcio, onnxruntime, tokenizers) ship manylinux wheels
|
||||
# for cp312, but keep a compiler around so a missing wheel degrades to a source
|
||||
# build instead of failing the image. Dropped from the final stage.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_DOWNLOADS=never
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Optional extras baked into the image. CPU-safe by default; GPU is a separate
|
||||
# Dockerfile. Pass a comma-separated list, e.g. EXTRAS="extract,spellcheck".
|
||||
ARG EXTRAS="extract,spellcheck"
|
||||
|
||||
# Layer 1: dependencies only (no project) — cached across source changes.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=README.md,target=README.md \
|
||||
set -e; \
|
||||
flags=""; \
|
||||
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
|
||||
uv sync --frozen --no-install-project --no-dev ${flags}
|
||||
|
||||
# Layer 2: the project itself. --no-editable installs mempalace into the venv's
|
||||
# site-packages (instead of an .pth pointing at /app), so the runtime stage can
|
||||
# copy only /app/.venv and drop the source tree.
|
||||
COPY . /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
set -e; \
|
||||
flags=""; \
|
||||
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
|
||||
uv sync --frozen --no-dev --no-editable ${flags}
|
||||
|
||||
# --- runtime ----------------------------------------------------------------
|
||||
FROM python:${PYTHON_VERSION}-slim AS runtime
|
||||
|
||||
LABEL org.opencontainers.image.title="MemPalace" \
|
||||
org.opencontainers.image.description="Local-first AI memory — verbatim storage, MCP server + CLI." \
|
||||
org.opencontainers.image.source="https://github.com/MemPalace/mempalace" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# /data is the single persistence root: HOME points here, so the palace
|
||||
# (~/.mempalace/palace), config (~/.mempalace), and the embedding-model cache
|
||||
# all land under one mountable volume. The default `minilm` model caches under
|
||||
# ~/.cache/chroma (~80 MB, from ChromaDB's S3); the optional `embeddinggemma`
|
||||
# model caches under ~/.cache/huggingface (~300 MB). Both lazy-download on
|
||||
# first use.
|
||||
ENV HOME=/data \
|
||||
PATH="/app/.venv/bin:${PATH}" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Non-root user owning the data volume.
|
||||
RUN groupadd --gid 1000 mempalace \
|
||||
&& useradd --uid 1000 --gid 1000 --home-dir /data --create-home mempalace
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# The resolved virtualenv from the builder — no build toolchain in this layer.
|
||||
COPY --from=builder --chown=mempalace:mempalace /app/.venv /app/.venv
|
||||
COPY --chown=mempalace:mempalace docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
USER mempalace
|
||||
VOLUME ["/data"]
|
||||
|
||||
# Default to the MCP server; `docker run` it with `-i` for stdio JSON-RPC.
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["mcp"]
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# MemPalace — GPU (NVIDIA CUDA) image.
|
||||
#
|
||||
# Multi-stage build using uv (the project ships a uv.lock, so we install from
|
||||
# the frozen lockfile for reproducible images). The `gpu` extra pulls
|
||||
# onnxruntime-gpu, which needs CUDA + cuDNN shared libraries at runtime, so
|
||||
# this variant builds on an nvidia/cuda base instead of python:slim.
|
||||
#
|
||||
# The builder stage is dropped from the final image, so build tools
|
||||
# (compilers, apt cache) never reach production — only the uv-managed
|
||||
# interpreter and the resolved virtualenv do.
|
||||
#
|
||||
# Build:
|
||||
# docker build -f Dockerfile.gpu -t mempalace:gpu .
|
||||
#
|
||||
# Run (requires the NVIDIA Container Toolkit on the host):
|
||||
# docker run -i --rm --gpus all \
|
||||
# -e MEMPALACE_EMBEDDING_DEVICE=cuda \
|
||||
# -v mempalace-data:/data mempalace:gpu
|
||||
#
|
||||
# NOTE: onnxruntime-gpu ties itself to a CUDA major version. If embeddings
|
||||
# fail to load on the GPU, align CUDA_IMAGE below with the CUDA release that
|
||||
# the resolved onnxruntime-gpu wheel targets (see its release notes), then
|
||||
# rebuild.
|
||||
|
||||
ARG CUDA_IMAGE=nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
|
||||
|
||||
# --- builder ----------------------------------------------------------------
|
||||
FROM ${CUDA_IMAGE} AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /uvx /bin/
|
||||
|
||||
# Minimal toolchain for any source-built wheels; dropped before the runtime
|
||||
# stage so compilers never ship in the production image.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG PYTHON_VERSION=3.12
|
||||
ARG EXTRAS="extract,spellcheck,gpu"
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
|
||||
UV_PYTHON_PREFERENCE=only-managed
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Layer 1: dependencies only (no project source). Bind-mounted files keep the
|
||||
# project tree out of this layer, so changing source code does not bust the
|
||||
# deps cache. The uv-managed interpreter is also installed here, into a
|
||||
# stable path that the runtime stage can copy verbatim.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=README.md,target=README.md \
|
||||
set -e; \
|
||||
uv python install ${PYTHON_VERSION}; \
|
||||
flags=""; \
|
||||
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
|
||||
uv sync --frozen --no-install-project --no-dev --python ${PYTHON_VERSION} ${flags}
|
||||
|
||||
# Layer 2: the project itself. --no-editable installs mempalace into the
|
||||
# venv's site-packages (instead of an .pth pointing at /app), so the runtime
|
||||
# stage can copy only /app/.venv and drop the source tree.
|
||||
COPY . /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
set -e; \
|
||||
flags=""; \
|
||||
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
|
||||
uv sync --frozen --no-dev --no-editable ${flags}
|
||||
|
||||
# --- runtime ----------------------------------------------------------------
|
||||
FROM ${CUDA_IMAGE} AS runtime
|
||||
|
||||
LABEL org.opencontainers.image.title="MemPalace (GPU)" \
|
||||
org.opencontainers.image.description="Local-first AI memory with CUDA-accelerated embeddings." \
|
||||
org.opencontainers.image.source="https://github.com/MemPalace/mempalace" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# ca-certificates only — needed for the lazy HuggingFace model download on
|
||||
# first use. No build toolchain in this stage.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV HOME=/data \
|
||||
PATH="/app/.venv/bin:${PATH}" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
MEMPALACE_EMBEDDING_DEVICE=cuda
|
||||
|
||||
# Non-root user owning the data volume.
|
||||
RUN groupadd --gid 1000 mempalace \
|
||||
&& useradd --uid 1000 --gid 1000 --home-dir /data --create-home mempalace
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Bring the uv-managed interpreter and the resolved venv across the stage
|
||||
# boundary. /opt/uv/python must be copied alongside .venv: the venv's
|
||||
# shebangs and binary launcher reference it.
|
||||
COPY --from=builder /opt/uv/python /opt/uv/python
|
||||
COPY --from=builder --chown=mempalace:mempalace /app/.venv /app/.venv
|
||||
COPY --chown=mempalace:mempalace docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
USER mempalace
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["mcp"]
|
||||
@@ -1,21 +1,3 @@
|
||||
> [!CAUTION]
|
||||
> # 🚨 CRITICAL SECURITY WARNING: BEWARE OF SCAMS
|
||||
> **MemPalace has NO other official websites.**
|
||||
>
|
||||
> The **ONLY** official sources are:
|
||||
> 1. This **[GitHub repository](https://github.com/MemPalace/mempalace)**
|
||||
> 2. The **[PyPI package](https://pypi.org/project/mempalace/)**
|
||||
> 3. The docs at **[mempalaceofficial.com](https://mempalaceofficial.com)**
|
||||
>
|
||||
> **ANY other domain** (including `.tech`, `.net`, or other `.com` variants) is an **impostor** and may distribute **malware**. Do not download executables from untrusted sites. Details and timeline: [docs/HISTORY.md](docs/HISTORY.md).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **🚨 Claude Code sessions expire in 30 days w/out auto-save hooks wired!** **[Read this →](https://github.com/MemPalace/mempalace/discussions/1388)**
|
||||
>
|
||||
> Need the shortest recovery/setup path? Use the
|
||||
> [Claude Code retention setup checklist](https://mempalaceofficial.com/guide/claude-code-retention.html).
|
||||
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/mempalace_logo.png" alt="MemPalace" width="240">
|
||||
@@ -31,6 +13,14 @@ Local-first AI memory. Verbatim storage, pluggable backend, 96.6% R@5 raw on Lon
|
||||
|
||||
</div>
|
||||
|
||||
> [!CAUTION]
|
||||
> **Beware of impostor sites.** MemPalace has no other official websites. The **only** official sources are this **[GitHub repository](https://github.com/MemPalace/mempalace)**, the **[PyPI package](https://pypi.org/project/mempalace/)**, and the docs at **[mempalaceofficial.com](https://mempalaceofficial.com)**. Any other domain (including `.tech`, `.net`, or other `.com` variants) is an impostor and may distribute malware. Details and timeline: [docs/HISTORY.md](docs/HISTORY.md).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Claude Code sessions expire in 30 days without auto-save hooks wired.** [Read this →](https://github.com/MemPalace/mempalace/discussions/1388)
|
||||
>
|
||||
> Need the shortest recovery/setup path? Use the [Claude Code retention setup checklist](https://mempalaceofficial.com/guide/claude-code-retention.html).
|
||||
|
||||
---
|
||||
|
||||
## What it is
|
||||
@@ -79,6 +69,79 @@ python -m venv .venv && source .venv/bin/activate
|
||||
pip install mempalace
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
A container image is also available for running the MCP server or the CLI
|
||||
without a local Python toolchain. Everything persists under `/data` (palace,
|
||||
config, and the cached embedding model), so mount a volume there.
|
||||
|
||||
```bash
|
||||
# Build the image (CPU; bundles the `extract` + `spellcheck` extras)
|
||||
docker build -t mempalace .
|
||||
|
||||
# MCP server over stdio — note the `-i` flag (JSON-RPC needs stdin)
|
||||
docker run -i --rm -v mempalace-data:/data mempalace
|
||||
|
||||
# Run any CLI command instead (mount the host directory you want to mine)
|
||||
docker run --rm -v mempalace-data:/data -v /path/to/project:/work mempalace mine /work
|
||||
docker run --rm -v mempalace-data:/data mempalace search "why GraphQL"
|
||||
```
|
||||
|
||||
Wire it into an MCP client (e.g. Claude Code) as a stdio server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "-v", "mempalace-data:/data", "mempalace"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`docker compose run --rm mcp` works too (see `docker-compose.yml`). For
|
||||
CUDA-accelerated embeddings, build the GPU variant with
|
||||
`docker build -f Dockerfile.gpu -t mempalace:gpu .` and run it with
|
||||
`--gpus all`. Customise the bundled extras at build time, e.g.
|
||||
`docker build --build-arg EXTRAS="extract,spellcheck" -t mempalace .`.
|
||||
|
||||
## Storage backends
|
||||
|
||||
ChromaDB is the default. For the pluggable-backend preview, MemPalace also
|
||||
ships `sqlite_exact` for local exact-vector correctness checks, and two opt-in
|
||||
external service backends — `qdrant` (REST) and `pgvector` (Postgres). The two
|
||||
external backends exercise the storage contract on different substrates (a
|
||||
REST/dict store and a SQL/JSONB store), so it is not accidentally shaped around
|
||||
one vendor.
|
||||
|
||||
```bash
|
||||
# local no-service backend
|
||||
mempalace mine ~/projects/myapp --backend sqlite_exact
|
||||
|
||||
# Qdrant backend, defaulting to http://localhost:6333
|
||||
MEMPALACE_QDRANT_URL=http://localhost:6333 \
|
||||
mempalace mine ~/projects/myapp --backend qdrant
|
||||
|
||||
# Postgres + pgvector backend, defaulting to postgresql://localhost:5432/mempalace
|
||||
# needs the optional driver: pip install mempalace[pgvector]
|
||||
# and the `vector` extension available on the server
|
||||
MEMPALACE_PGVECTOR_DSN=postgresql://localhost:5432/mempalace \
|
||||
mempalace mine ~/projects/myapp --backend pgvector
|
||||
```
|
||||
|
||||
Qdrant can also be configured with `MEMPALACE_QDRANT_API_KEY`,
|
||||
`MEMPALACE_QDRANT_NAMESPACE`, and `MEMPALACE_QDRANT_TIMEOUT`; pgvector with
|
||||
`MEMPALACE_PGVECTOR_NAMESPACE`. Both external backends isolate tenants by
|
||||
namespace (advertised via the `supports_namespace_isolation` capability) and
|
||||
write a local marker (`qdrant_backend.json` / `pgvector_backend.json`) to guard
|
||||
against silently opening a palace against the wrong server.
|
||||
|
||||
When `MEMPALACE_QDRANT_URL` or `MEMPALACE_PGVECTOR_DSN` points anywhere other
|
||||
than your own local or trusted self-hosted service, MemPalace will send and
|
||||
store verbatim drawer text and metadata there. That is an explicit opt-in
|
||||
backend choice, never the default.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
@@ -93,7 +156,8 @@ mempalace search "why did we switch to GraphQL"
|
||||
mempalace wake-up
|
||||
```
|
||||
|
||||
For Claude Code, Gemini CLI, MCP-compatible tools, and local models, see
|
||||
For Claude Code, Gemini CLI, [Antigravity](https://mempalaceofficial.com/guide/antigravity.html),
|
||||
MCP-compatible tools, and local models, see
|
||||
[mempalaceofficial.com/guide/getting-started](https://mempalaceofficial.com/guide/getting-started.html).
|
||||
|
||||
---
|
||||
@@ -161,7 +225,7 @@ Usage and tool reference:
|
||||
|
||||
## MCP server
|
||||
|
||||
29 MCP tools cover palace reads/writes, knowledge-graph operations,
|
||||
33 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).
|
||||
@@ -175,8 +239,14 @@ system prompt:
|
||||
|
||||
## Auto-save hooks
|
||||
|
||||
Two Claude Code hooks save periodically and before context compression:
|
||||
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html).
|
||||
Auto-save hooks for **Claude Code, Codex CLI, and Cursor IDE** save
|
||||
periodically and before context compression:
|
||||
|
||||
- Claude Code + Codex →
|
||||
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html)
|
||||
- Cursor IDE (adds session-start recall and a transcript snapshot before
|
||||
compaction) →
|
||||
[mempalaceofficial.com/guide/cursor-hooks](https://mempalaceofficial.com/guide/cursor-hooks.html)
|
||||
|
||||
If you are installing under time pressure, start with the
|
||||
[Claude Code retention setup checklist](https://mempalaceofficial.com/guide/claude-code-retention.html):
|
||||
@@ -215,7 +285,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
<!-- Link Definitions -->
|
||||
[version-shield]: https://img.shields.io/badge/version-3.3.6-4dc9f6?style=flat-square&labelColor=0a0e14
|
||||
[version-shield]: https://img.shields.io/badge/version-3.4.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/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Show comprehensive MemPalace help — available skills, MCP tools, CLI commands, hooks, and architecture.
|
||||
---
|
||||
|
||||
Invoke the `mempalace` skill from this plugin and run the `help` instructions, then follow them.
|
||||
|
||||
Concretely: run `mempalace instructions help` in a terminal, then carry out the steps it prints.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: Set up MemPalace — install the package, initialize a palace, register the MCP server with Cursor, and verify everything works.
|
||||
---
|
||||
|
||||
Invoke the `mempalace` skill from this plugin and run the `init` instructions, then follow them.
|
||||
|
||||
Concretely: run `mempalace instructions init` in a terminal, then carry out the steps it prints.
|
||||
|
||||
Cursor-specific extras after init:
|
||||
|
||||
1. The `mempalace-mcp` server is already auto-registered by this plugin — no manual `mcp.json` edit needed.
|
||||
2. For automatic background saves and session-start memory recall, also run `hooks/cursor/install.sh --scope user` from a cloned MemPalace repo. See `website/guide/cursor-hooks.md` for the walkthrough.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Mine projects and conversations into the MemPalace. Supports project files, conversation exports, and auto-classification.
|
||||
---
|
||||
|
||||
Invoke the `mempalace` skill from this plugin and run the `mine` instructions, then follow them.
|
||||
|
||||
Concretely: run `mempalace instructions mine` in a terminal, then carry out the steps it prints.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Search your memories across the MemPalace using semantic search with wing/room filtering.
|
||||
---
|
||||
|
||||
Invoke the `mempalace` skill from this plugin and run the `search` instructions, then follow them.
|
||||
|
||||
Concretely: run `mempalace instructions search` in a terminal, then carry out the steps it prints. The MCP tool `mempalace_search` is also available directly from this Cursor session.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Show the current state of your memory palace — wings, rooms, drawer counts, and suggestions.
|
||||
---
|
||||
|
||||
Invoke the `mempalace` skill from this plugin and run the `status` instructions, then follow them.
|
||||
|
||||
Concretely: run `mempalace instructions status` in a terminal, then carry out the steps it prints.
|
||||
@@ -0,0 +1,32 @@
|
||||
# MemPalace via Docker Compose.
|
||||
#
|
||||
# The MCP server speaks JSON-RPC over stdio, so the `mcp` service is meant to
|
||||
# be driven interactively (`docker compose run`), not left running detached:
|
||||
#
|
||||
# docker compose build
|
||||
# docker compose run --rm mcp # MCP server over stdio
|
||||
# docker compose run --rm mcp cli search "GraphQL" # one-off CLI command
|
||||
#
|
||||
# The named volume `mempalace-data` is mounted at /data and holds the palace,
|
||||
# config, and the cached embedding model across runs.
|
||||
|
||||
services:
|
||||
mcp:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: mempalace:local
|
||||
# stdio transport: keep STDIN open and allocate no TTY (raw JSON-RPC).
|
||||
stdin_open: true
|
||||
tty: false
|
||||
volumes:
|
||||
- mempalace-data:/data
|
||||
environment:
|
||||
# Everything defaults correctly from HOME=/data (palace ->
|
||||
# /data/.mempalace/palace, config -> /data/.mempalace, model cache ->
|
||||
# /data/.cache). Override here only for non-default locations, e.g.:
|
||||
# - MEMPALACE_EMBEDDING_MODEL=embeddinggemma # multilingual (default: minilm)
|
||||
# - MEMPALACE_PALACE_PATH=/data/custom/palace
|
||||
|
||||
volumes:
|
||||
mempalace-data:
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env sh
|
||||
# Flexible entrypoint: pick the MCP server or the CLI from the first argument.
|
||||
#
|
||||
# docker run -i mempalace -> MCP server over stdio (default)
|
||||
# docker run -i mempalace mcp -> MCP server over stdio (explicit)
|
||||
# docker run mempalace cli search "query" -> CLI passthrough (explicit)
|
||||
# docker run mempalace search "query" -> CLI passthrough (implicit)
|
||||
#
|
||||
# `mcp` and `cli` are dispatch keywords; anything else is forwarded to the
|
||||
# `mempalace` CLI verbatim so subcommands like `mine`, `search`, `wake-up`
|
||||
# work without ceremony.
|
||||
set -e
|
||||
|
||||
case "${1:-mcp}" in
|
||||
mcp)
|
||||
if [ "$#" -gt 0 ]; then
|
||||
shift
|
||||
fi
|
||||
exec mempalace-mcp "$@"
|
||||
;;
|
||||
cli)
|
||||
if [ "$#" -gt 0 ]; then
|
||||
shift
|
||||
fi
|
||||
exec mempalace "$@"
|
||||
;;
|
||||
*)
|
||||
exec mempalace "$@"
|
||||
;;
|
||||
esac
|
||||
@@ -29,3 +29,59 @@ If `pyproject.toml` has no match, **stop** — the entry point is missing and
|
||||
any fresh `pip install` will ship a broken plugin config. Investigate whether
|
||||
the release branch was cut before
|
||||
[#340](https://github.com/MemPalace/mempalace/pull/340) landed on `develop`.
|
||||
|
||||
## Publishing to PyPI
|
||||
|
||||
Releases publish automatically via the
|
||||
[`publish.yml`](../.github/workflows/publish.yml) workflow, using PyPI
|
||||
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC). There
|
||||
is **no API token** stored anywhere — GitHub mints a short-lived identity at
|
||||
upload time. The workflow fires when a **GitHub Release is published**, builds
|
||||
the sdist + wheel, and pauses for manual approval on the `pypi` environment
|
||||
before uploading.
|
||||
|
||||
### One-time setup (owners only)
|
||||
|
||||
Done once per project; both steps require PyPI owner / GitHub admin rights.
|
||||
|
||||
1. **PyPI trusted publisher** — on PyPI, go to **Manage project `mempalace`
|
||||
→ Publishing → Add a trusted publisher** and enter exactly:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Owner | `MemPalace` |
|
||||
| Repository name | `mempalace` |
|
||||
| Workflow filename | `publish.yml` |
|
||||
| Environment name | `pypi` |
|
||||
|
||||
2. **GitHub environment** — in the repo, **Settings → Environments → New
|
||||
environment** named `pypi`. Add yourself (and any other release approvers)
|
||||
under **Required reviewers**. This is the manual gate the workflow waits on
|
||||
before the upload step runs.
|
||||
|
||||
### Cutting a release
|
||||
|
||||
1. Bump the version in **all five** sources on `develop` so `version-guard.yml`
|
||||
stays green (it is the single source of truth at `mempalace/version.py`,
|
||||
mirrored in `pyproject.toml`, `.claude-plugin/marketplace.json`,
|
||||
`.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json`).
|
||||
2. Land everything for the release on `develop`, then merge `develop → main`.
|
||||
Releases publish **only from `main`** — the workflow refuses any tag whose
|
||||
commit is not an ancestor of `main`. Don't commit the bump directly to
|
||||
`main`: it bypasses branch protection and leaves `develop` behind.
|
||||
3. Run the **entry-point alignment check** above.
|
||||
4. On GitHub, **Releases → Draft a new release**:
|
||||
- **Target:** `main`
|
||||
- **Tag:** `vX.Y.Z` (must equal `mempalace/version.py`; the workflow and
|
||||
`version-guard.yml` both reject a mismatch)
|
||||
- Write the release notes, then **Publish release**.
|
||||
5. The `publish.yml` run validates the tag (on `main`, matches the manifest),
|
||||
builds, and then waits for approval on the `pypi` environment. Approve it to
|
||||
upload to PyPI. Watch the run land the new version on
|
||||
<https://pypi.org/project/mempalace/>.
|
||||
|
||||
To stage a release candidate without shipping to end users, tag a semver
|
||||
pre-release (`vX.Y.Z-rc1`) — `version-guard.yml` skips the strict manifest
|
||||
match for pre-release tags. (Note: a published GitHub Release still triggers
|
||||
`publish.yml`; use a **draft** release, or a plain pushed tag, for dry runs you
|
||||
don't want uploaded.)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Recovery: legacy wing names split after the normalization change
|
||||
|
||||
**Companion to #1675.** `normalize_wing_name` now strips leading and trailing
|
||||
separators, so a path-encoded project dir like `-home-user-proj` derives the
|
||||
wing `home_user_proj` instead of `_home_user_proj`. Palaces mined before that
|
||||
change filed drawers under the old, separator-padded name. New mining and diary
|
||||
writes land on the new name, so the two no longer meet — the history is
|
||||
**split**, not lost. `mempalace migrate-wings` re-unites them.
|
||||
|
||||
## Symptom
|
||||
|
||||
After upgrading, a project that used to surface its memories returns less than
|
||||
expected, and `mempalace status` shows two wings for one project — e.g. both
|
||||
`_home_user_proj` (old drawers) and `home_user_proj` (newly mined). MCP writes
|
||||
to the padded wing may also have been rejected, since `sanitize_name` does not
|
||||
accept a leading underscore.
|
||||
|
||||
## Recovery
|
||||
|
||||
Preview first — this never modifies anything:
|
||||
|
||||
```bash
|
||||
mempalace migrate-wings --dry-run
|
||||
mempalace migrate-wings --dry-run --palace /path/to/palace
|
||||
```
|
||||
|
||||
The plan lists each rename and flags collisions that will **merge** into an
|
||||
existing wing:
|
||||
|
||||
```
|
||||
Wing-name migration plan:
|
||||
'_home_user_proj' -> 'home_user_proj': 1284 drawer(s), 96 closet(s) (MERGE into existing wing)
|
||||
```
|
||||
|
||||
Apply it:
|
||||
|
||||
```bash
|
||||
mempalace migrate-wings # prompts for confirmation
|
||||
mempalace migrate-wings --yes # no prompt
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
- Re-keys the `wing` **metadata field** on drawers and closets to the normalized
|
||||
form, merging collisions into the existing wing.
|
||||
- Re-keys the `topics_by_wing` registry (merging topic lists on collision).
|
||||
|
||||
## What it leaves alone
|
||||
|
||||
- **Drawer/closet IDs** are untouched. The wing in an ID (`drawer_<wing>_…`) is
|
||||
an opaque prefix that is never decoded back into a wing, so leaving it keeps
|
||||
closet `→drawer_id` pointers valid and lets future mining still skip
|
||||
already-mined files (no duplicates). The verbatim drawer content is never
|
||||
read or rewritten.
|
||||
- **Tunnels** already normalize wing names at read time, so they resolve under
|
||||
the new name without a rewrite.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Idempotent.** A second run reports "nothing to migrate" and changes nothing.
|
||||
- **Backend-agnostic.** Works on any configured storage backend.
|
||||
- Run it once per palace after upgrading. New palaces are born with normalized
|
||||
wing names and never need it.
|
||||
@@ -0,0 +1,74 @@
|
||||
# MemPalace — Antigravity examples
|
||||
|
||||
Two standalone configs for users who don't want to use the
|
||||
`hooks/antigravity/install.sh` installer.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|-------------------|-----------------------------------------------------------------------------------|
|
||||
| `hooks.json` | Standalone `hooks.json` registering the Stop and PreInvocation hooks. |
|
||||
| `mcp_config.json` | Standalone MCP entry registering the `mempalace-mcp` stdio server. |
|
||||
|
||||
## Wire up `hooks.json`
|
||||
|
||||
The example uses placeholder absolute paths (`/ABSOLUTE/PATH/TO/mempalace/...`).
|
||||
You must rewrite both `command` fields to the actual absolute paths to
|
||||
the hook scripts in your cloned repo, or to whichever location holds
|
||||
them. Antigravity will not resolve relative paths reliably.
|
||||
|
||||
Then drop the file at one of:
|
||||
|
||||
- `~/.gemini/config/hooks.json` (global, applies to every workspace)
|
||||
- `<workspace>/.agents/hooks.json` (workspace-scoped)
|
||||
|
||||
Restart Antigravity to pick the file up.
|
||||
|
||||
If you'd rather have the paths absolutized automatically, run the
|
||||
installer:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh
|
||||
```
|
||||
|
||||
That writes a fully rendered `hooks.json` to
|
||||
`~/.gemini/config/plugins/mempalace/hooks.json`.
|
||||
|
||||
## Wire up `mcp_config.json`
|
||||
|
||||
The example registers the `mempalace-mcp` stdio server. Two options:
|
||||
|
||||
### Option A — merge into the user-level Antigravity MCP config
|
||||
|
||||
Antigravity's user-level MCP config lives at
|
||||
`~/.gemini/antigravity/mcp_config.json`. Merge the `mcpServers.mempalace`
|
||||
entry from this example into that file, then restart Antigravity.
|
||||
|
||||
### Option B — drop into a plugin directory
|
||||
|
||||
If you've created a custom plugin folder (per the [Antigravity plugins docs](https://antigravity.google/docs/plugins)),
|
||||
copy this `mcp_config.json` directly into the plugin root:
|
||||
|
||||
```
|
||||
<plugin-root>/mcp_config.json
|
||||
```
|
||||
|
||||
Antigravity merges plugin-level MCP entries with the user-level config
|
||||
on launch.
|
||||
|
||||
## Verify
|
||||
|
||||
After wiring up either or both:
|
||||
|
||||
```bash
|
||||
mempalace-mcp --version # confirm binary is on PATH
|
||||
ls ~/.mempalace/ # confirm palace exists (run `mempalace init` if not)
|
||||
```
|
||||
|
||||
Restart Antigravity. The `mempalace` MCP server should appear in the
|
||||
MCP store; the Stop and PreInvocation hooks fire automatically.
|
||||
|
||||
See [`hooks/antigravity/STDIN_SHAPE.md`](../../hooks/antigravity/STDIN_SHAPE.md)
|
||||
for the exact wire format Antigravity uses, and
|
||||
[`website/guide/antigravity.md`](../../website/guide/antigravity.md)
|
||||
for the full user-facing guide.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"mempalace-save": {
|
||||
"Stop": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/ABSOLUTE/PATH/TO/mempalace/hooks/antigravity/mempal_save_hook_antigravity.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"mempalace-wake": {
|
||||
"PreInvocation": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/ABSOLUTE/PATH/TO/mempalace/hooks/antigravity/mempal_wake_hook_antigravity.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# Cursor IDE Hooks — Example `hooks.json` Files
|
||||
|
||||
Sample configurations for wiring the MemPalace Cursor hooks into the
|
||||
Cursor IDE. These are **examples only** — they are intentionally not
|
||||
placed at the repo root (`/.cursor/hooks.json`) because Cursor
|
||||
auto-loads project-level hooks from any trusted workspace, and the
|
||||
repo is regularly opened by contributors. We do not auto-arm hooks on
|
||||
contributor checkout.
|
||||
|
||||
## Variants
|
||||
|
||||
### `hooks.json` — full (recommended)
|
||||
|
||||
Three hooks wired:
|
||||
|
||||
- **`sessionStart`** — calls `mempal_wake_hook_cursor.sh`, which returns
|
||||
`additional_context` telling the agent to recall scoped to the wing
|
||||
inferred from the workspace root. Cursor-only — Claude Code has no
|
||||
equivalent.
|
||||
- **`stop`** — calls `mempal_save_hook_cursor.sh`. Counts stop
|
||||
invocations per conversation and emits a `followup_message` every
|
||||
`MEMPAL_SAVE_INTERVAL` (default 15) telling the agent to file the
|
||||
session into the palace and write a diary entry. `loop_limit: 1` is
|
||||
defense-in-depth on top of our own loop-count check.
|
||||
- **`preCompact`** — calls `mempal_precompact_hook_cursor.sh`. Runs
|
||||
`mempalace mine` synchronously on the transcript before compaction
|
||||
summarises it, then drops a marker so the next `stop` forces a save
|
||||
followup.
|
||||
|
||||
### `hooks.minimal.json` — `stop` only
|
||||
|
||||
Lightest install. Wires just the save hook. Use this if you don't
|
||||
want the sessionStart recall context or the preCompact transcript
|
||||
snapshot.
|
||||
|
||||
## How to use
|
||||
|
||||
The `$HOME` placeholder is **not** expanded by Cursor — you must
|
||||
substitute the absolute path before saving the file. Pick one:
|
||||
|
||||
### Option A — let `install.sh` do it
|
||||
|
||||
Project scope — writes `<repo>/.cursor/hooks.json`:
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope project --target /path/to/your/repo
|
||||
```
|
||||
|
||||
User scope — writes `~/.cursor/hooks.json`, applies to every Cursor workspace:
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope user
|
||||
```
|
||||
|
||||
The installer copies the hook scripts to `~/.mempalace/hooks/cursor/`,
|
||||
substitutes the absolute paths, and merges the entries into your
|
||||
existing `hooks.json` without clobbering unrelated hooks. See
|
||||
`install.sh --help` for `--dry-run`, `--uninstall`, and `--variant`.
|
||||
|
||||
### Option B — copy + edit manually
|
||||
|
||||
1. Copy the chosen example to the target location:
|
||||
- User scope: `~/.cursor/hooks.json`
|
||||
- Project scope: `<your-repo>/.cursor/hooks.json`
|
||||
2. Replace every `$HOME` with the absolute path to your home
|
||||
directory (e.g., `/Users/you` or `/home/you`).
|
||||
3. Make sure each hook script is executable
|
||||
(`chmod +x ~/.mempalace/hooks/cursor/mempal_*_hook_cursor.sh`).
|
||||
4. Restart Cursor, or wait for it to auto-reload the file.
|
||||
|
||||
## Why aren't these files at the repo root?
|
||||
|
||||
Cursor automatically loads `.cursor/hooks.json` from any trusted
|
||||
workspace. Placing a real `hooks.json` at the repo root would arm
|
||||
MemPalace's hooks on every contributor's machine the moment they open
|
||||
the repo in Cursor — which would modify their conversation behaviour
|
||||
without consent and write to `~/.mempalace/hook_state/` without
|
||||
asking. Editor configuration is sacred; opt-in only.
|
||||
|
||||
If you actually want MemPalace's hooks armed when working on the
|
||||
MemPalace repo itself, run:
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope project --target .
|
||||
```
|
||||
|
||||
That will write `./.cursor/hooks.json` for the repo workspace
|
||||
specifically — but it is your decision, not ours, and the file is
|
||||
listed in `.gitignore` paths Cursor users typically already exclude.
|
||||
|
||||
## Related: the Cursor plugin
|
||||
|
||||
The hooks here are **only one half** of MemPalace's Cursor integration. The other half is the [`.cursor-plugin/`](../../.cursor-plugin/) folder at the repo root, which packages MemPalace's MCP server, five slash commands, and the model-invocable `mempalace` skill as a regular Cursor plugin you can drop into `~/.cursor/plugins/local/mempalace`.
|
||||
|
||||
The two install paths are orthogonal — install whichever you want, in any order:
|
||||
|
||||
| You want | Install |
|
||||
|---------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| MCP tools (`mempalace_search`, `mempalace_add_drawer`, …) + slash commands | The plugin — see [`.cursor-plugin/README.md`](../../.cursor-plugin/README.md) |
|
||||
| Auto-save every N turns + sessionStart memory recall | The hooks here — see Option A above |
|
||||
| Both | Install the plugin AND run `hooks/cursor/install.sh` |
|
||||
|
||||
Hooks are deliberately **not** bundled into the plugin because Cursor's hooks system is configured per-user/per-project (in `~/.cursor/hooks.json` or `.cursor/hooks.json`), not per-plugin — so the installer here owns that file with idempotent merge semantics, while the plugin owns the MCP+commands+skill side.
|
||||
|
||||
## See also
|
||||
|
||||
- [`hooks/cursor/README.md`](../../hooks/cursor/README.md) — full reference for hooks
|
||||
- [`hooks/cursor/STDIN_SHAPE.md`](../../hooks/cursor/STDIN_SHAPE.md) — per-event schema with citations
|
||||
- [`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) — rendered docs
|
||||
- [`.cursor-plugin/README.md`](../../.cursor-plugin/README.md) — Cursor plugin (MCP + commands + skill)
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"sessionStart": [
|
||||
{
|
||||
"command": "$HOME/.mempalace/hooks/cursor/mempal_wake_hook_cursor.sh"
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
{
|
||||
"command": "$HOME/.mempalace/hooks/cursor/mempal_save_hook_cursor.sh",
|
||||
"loop_limit": 1
|
||||
}
|
||||
],
|
||||
"preCompact": [
|
||||
{
|
||||
"command": "$HOME/.mempalace/hooks/cursor/mempal_precompact_hook_cursor.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"stop": [
|
||||
{
|
||||
"command": "$HOME/.mempalace/hooks/cursor/mempal_save_hook_cursor.sh",
|
||||
"loop_limit": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Cursor Rules — MemPalace recall
|
||||
|
||||
Optional [Cursor rules](https://cursor.com/docs/rules) that make the
|
||||
agent search MemPalace before answering questions about past work,
|
||||
people, projects, or prior decisions.
|
||||
|
||||
These are for users who install MemPalace **without** the Cursor plugin
|
||||
(or who want recall behaviour in a specific project). If you installed
|
||||
the [Cursor plugin](../../../.cursor-plugin/README.md), it already ships
|
||||
the `alwaysApply: false` rule at the plugin root — you do not need to
|
||||
copy anything.
|
||||
|
||||
## Which file to use
|
||||
|
||||
| File | `alwaysApply` | Fires when | Use when |
|
||||
|------|---------------|------------|----------|
|
||||
| [`mempalace-recall.mdc`](mempalace-recall.mdc) | `false` | Cursor's matcher decides the turn is recall-relevant (from the rule `description`) | **Recommended.** Recall without paying for the rule on unrelated work. |
|
||||
| [`mempalace-recall-always.mdc`](mempalace-recall-always.mdc) | `true` | Every conversation in scope, every turn | You want recall guaranteed in context and accept the cost. |
|
||||
|
||||
The always-on variant is heavier: it sits in context on every turn and
|
||||
makes the agent more eager to call `mempalace_search`, which adds MCP
|
||||
latency and works against MemPalace's "memory should feel instant"
|
||||
budget. Prefer the `false` variant unless you specifically want recall
|
||||
forced into every conversation. Pick **one** of the two — do not install
|
||||
both.
|
||||
|
||||
## Install
|
||||
|
||||
User scope (every workspace) — copy into `~/.cursor/rules/`:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.cursor/rules
|
||||
cp examples/cursor/rules/mempalace-recall.mdc ~/.cursor/rules/
|
||||
```
|
||||
|
||||
Project scope (this repo only) — copy into `.cursor/rules/`:
|
||||
|
||||
```bash
|
||||
mkdir -p .cursor/rules
|
||||
cp examples/cursor/rules/mempalace-recall.mdc .cursor/rules/
|
||||
```
|
||||
|
||||
For the aggressive variant, copy `mempalace-recall-always.mdc` instead
|
||||
(only one of the two). Then reload Cursor:
|
||||
<kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
|
||||
|
||||
## How recall is delivered
|
||||
|
||||
Recall ships in three orthogonal layers — install any combination:
|
||||
|
||||
| Layer | What it does | Where |
|
||||
|-------|--------------|-------|
|
||||
| `sessionStart` hook | Injects wing-scoped recall context once per new chat | [`hooks/cursor/`](../../../hooks/cursor/) |
|
||||
| `mempalace-recall` skill | Full search-before-answer protocol, model-invoked or attached | [`skills/mempalace-recall/`](../../../skills/mempalace-recall/) |
|
||||
| Recall rule (these files) | Nudges search-before-answer on recall-relevant turns | here, or the plugin root `rules/` |
|
||||
|
||||
All three reference the same canonical protocol in
|
||||
[`integrations/shared/recall-protocol.md`](../../../integrations/shared/recall-protocol.md).
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Always-on MemPalace recall — search the palace before answering about past work, people, projects, or prior decisions.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# MemPalace recall (always on)
|
||||
|
||||
This is the aggressive variant of the recall rule: `alwaysApply: true`
|
||||
loads it into every conversation in scope, on every turn, regardless of
|
||||
whether Cursor's matcher thinks recall is relevant.
|
||||
|
||||
Before answering anything that may already be in the user's memory
|
||||
palace — past work, prior decisions, a person, a project, or "what did
|
||||
we do / decide / discuss last time?" — search the palace first:
|
||||
|
||||
1. Call `mempalace_search` with a short keyword query. Use
|
||||
`mempalace_kg_query` for relational or time-bound facts.
|
||||
2. Quote the drawer's **verbatim** text. Never summarize or paraphrase
|
||||
stored content.
|
||||
3. If results are empty, say so — do not invent an answer. If the MCP
|
||||
server is unavailable, surface the error; do not fall back to guessing.
|
||||
|
||||
Even with this rule always loaded, only actually call the tools when the
|
||||
question touches memory. Do not search on pure greenfield work
|
||||
(renaming a variable, fixing a typo).
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: When the user asks about past work, prior decisions, people, projects, or events that may be filed in MemPalace, call mempalace_search (or mempalace_kg_query for relational or time-bound facts) before answering from model memory. Return stored content verbatim; never guess when the palace might know.
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# MemPalace recall
|
||||
|
||||
Before answering anything that may already be in the user's memory
|
||||
palace — past work, prior decisions, a person, a project, or "what did
|
||||
we do / decide / discuss last time?" — search the palace first:
|
||||
|
||||
1. Call `mempalace_search` with a short keyword query. Use
|
||||
`mempalace_kg_query` for relational or time-bound facts.
|
||||
2. Quote the drawer's **verbatim** text. Never summarize or paraphrase
|
||||
stored content.
|
||||
3. If results are empty, say so — do not invent an answer. If the MCP
|
||||
server is unavailable, surface the error; do not fall back to guessing.
|
||||
|
||||
Skip recall for pure greenfield work with no memory relevance (renaming
|
||||
a variable, fixing a typo). Recall is question-driven, not reflexive.
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
These hook scripts make MemPalace save automatically. No manual "save" commands needed.
|
||||
|
||||
This file covers the **Claude Code** and **Codex CLI** hooks that live
|
||||
flat under `hooks/`. For the **Cursor IDE** hooks, see
|
||||
[`hooks/cursor/README.md`](cursor/README.md) or the rendered docs at
|
||||
[`website/guide/cursor-hooks.md`](../website/guide/cursor-hooks.md). The
|
||||
two are additive and share the same `~/.mempalace/hook_state/`
|
||||
directory.
|
||||
|
||||
If you are trying to protect existing Claude Code transcripts immediately,
|
||||
use the short checklist first: [`website/guide/claude-code-retention.md`](../website/guide/claude-code-retention.md).
|
||||
It covers hook wiring, JSONL backup, and one-time backfill.
|
||||
@@ -46,6 +53,24 @@ Make them executable:
|
||||
chmod +x hooks/mempal_save_hook.sh hooks/mempal_precompact_hook.sh
|
||||
```
|
||||
|
||||
## Install — Antigravity (Google)
|
||||
|
||||
The Antigravity integration lives in its own subdirectory because the
|
||||
wire format (camelCase JSON, `injectSteps[]` output) and event names
|
||||
(`Stop`, `PreInvocation`) are Antigravity-specific. Use the dedicated
|
||||
installer:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh
|
||||
```
|
||||
|
||||
This installs to `~/.gemini/config/plugins/mempalace/`, registers the
|
||||
MCP server, ships the `mempalace` skill, and wires the Stop +
|
||||
PreInvocation hooks. See [`hooks/antigravity/README.md`](antigravity/README.md)
|
||||
for the full guide and [`hooks/antigravity/INVESTIGATION.md`](antigravity/INVESTIGATION.md)
|
||||
for the source-of-truth audit of which Antigravity surfaces the
|
||||
integration uses.
|
||||
|
||||
## Install — Codex CLI (OpenAI)
|
||||
|
||||
Add to `.codex/hooks.json`:
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# Antigravity IDE — Integration Surface Investigation
|
||||
|
||||
**Investigated**: 2026-05-27
|
||||
**Author**: undeadindustries
|
||||
**Scope**: What MemPalace can integrate with in Google's Antigravity IDE,
|
||||
what we shipped, and what we deliberately did not ship.
|
||||
|
||||
This document is the source of truth for design decisions in the
|
||||
`feat/antigravity-support` branch. It exists so a future maintainer can
|
||||
re-derive every choice without re-reading the docs cold.
|
||||
|
||||
---
|
||||
|
||||
## 1. Surfaces verified against Google's official Antigravity docs
|
||||
|
||||
All quotes are pulled verbatim from Google's official Antigravity
|
||||
documentation on 2026-05-27. URLs are the authoritative source; the
|
||||
mirrored excerpts here are for reviewer convenience.
|
||||
|
||||
### 1.1. MCP — `https://antigravity.google/docs/mcp`
|
||||
|
||||
> The configuration file is located at `~/.gemini/antigravity/mcp_config.json`.
|
||||
>
|
||||
> The configuration file has a single `mcpServers` object where you
|
||||
> define each server you want to connect to.
|
||||
|
||||
Schema (verified):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"<name>": {
|
||||
"command": "...", // stdio
|
||||
"args": [...],
|
||||
"env": {...},
|
||||
"cwd": "...",
|
||||
"serverUrl": "...", // remote
|
||||
"headers": {...},
|
||||
"authProviderType": "google_credentials",
|
||||
"oauth": {"clientId": "...", "clientSecret": "..."},
|
||||
"disabled": false,
|
||||
"disabledTools": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-plugin form: `mcp_config.json` at the plugin root, same shape.
|
||||
Antigravity merges plugin entries with the user's
|
||||
`~/.gemini/antigravity/mcp_config.json` rather than clobbering.
|
||||
|
||||
**Cross-checked locally**: the user's existing
|
||||
`~/.gemini/antigravity/mcp_config.json` already contains a working
|
||||
`"mempalace": {"command": "/Users/robs/.local/bin/mempalace-mcp"}`
|
||||
entry, proving the shape matches and the binary is on PATH.
|
||||
|
||||
### 1.2. Plugins — `https://antigravity.google/docs/plugins`
|
||||
|
||||
> A plugin is a directory containing a `plugin.json` file and optional
|
||||
> subdirectories for different customization types:
|
||||
>
|
||||
> ```
|
||||
> plugins/<plugin-name>/
|
||||
> ├── plugin.json # Required marker file
|
||||
> ├── mcp_config.json # Optional MCP server definitions
|
||||
> ├── hooks.json # Optional hooks definition
|
||||
> ├── skills/ # Optional skills
|
||||
> │ └── <skill-name>/
|
||||
> │ └── SKILL.md
|
||||
> └── rules/ # Optional rules
|
||||
> └── <rule-name>.md
|
||||
> ```
|
||||
|
||||
Manifest schema (verified):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-custom-plugin"
|
||||
}
|
||||
```
|
||||
|
||||
> The name field is optional and defaults to the directory name if omitted.
|
||||
|
||||
Install locations (verified):
|
||||
|
||||
> - Workspace Level: Place your plugin folder inside a
|
||||
> `.agents/plugins/` or `_agents/plugins/` directory at the root of
|
||||
> your opened workspace.
|
||||
> - Global Level: Place your plugin folder inside
|
||||
> `~/.gemini/config/plugins/` in your user home directory.
|
||||
|
||||
We ship the global location as the canonical install path.
|
||||
|
||||
### 1.3. Skills — `https://antigravity.google/docs/skills`
|
||||
|
||||
> A skill is a folder containing a `SKILL.md` file with instructions
|
||||
> that the agent can follow when working on specific tasks.
|
||||
|
||||
Frontmatter (verified):
|
||||
|
||||
| Field | Required | Description |
|
||||
|---------------|----------|---------------------------------------------------------------------------------------------------|
|
||||
| `name` | No | A unique identifier (lowercase, hyphens). Defaults to the folder name. |
|
||||
| `description` | Yes | A clear description of what the skill does and when to use it. |
|
||||
|
||||
Standalone discovery paths (verified):
|
||||
|
||||
| Location | Scope |
|
||||
|-------------------------------------|------------------------|
|
||||
| `<workspace>/.agents/skills/` | Workspace-specific |
|
||||
| `~/.gemini/antigravity/skills/` | Global, all workspaces |
|
||||
|
||||
In-plugin discovery: `<plugin>/skills/<skill-name>/SKILL.md`.
|
||||
|
||||
We ship the in-plugin form so a single install registers MCP, skill, and
|
||||
hooks together.
|
||||
|
||||
### 1.4. Hooks — `https://antigravity.google/docs/hooks?app=antigravity`
|
||||
|
||||
> Hooks allow you to run custom scripts or shell commands at specific
|
||||
> points during Antigravity's execution loop.
|
||||
|
||||
`hooks.json` lives at one of:
|
||||
- `~/.gemini/config/hooks.json` (global)
|
||||
- `<workspace>/.agents/hooks.json` (workspace)
|
||||
- `<plugin-root>/hooks.json` (per-plugin) — what we ship
|
||||
|
||||
Top-level schema (verified):
|
||||
|
||||
```json
|
||||
{
|
||||
"<hook-name>": {
|
||||
"enabled": true,
|
||||
"PreToolUse": [{ "matcher": "...", "hooks": [{...}] }],
|
||||
"PostToolUse": [{ "matcher": "...", "hooks": [{...}] }],
|
||||
"PreInvocation": [{ ... }],
|
||||
"PostInvocation": [{ ... }],
|
||||
"Stop": [{ ... }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For `PreInvocation` / `PostInvocation` / `Stop`, items are flat handler
|
||||
objects; the `matcher` wrapper is only used for `PreToolUse` /
|
||||
`PostToolUse`.
|
||||
|
||||
Handler object (verified):
|
||||
|
||||
| Field | Required | Description |
|
||||
|-----------|----------|---------------------------------------------------|
|
||||
| `type` | No | Currently only `"command"` is supported. Default. |
|
||||
| `command` | Yes | The shell command to execute. |
|
||||
| `timeout` | No | Timeout in seconds. Defaults to 30. |
|
||||
|
||||
#### STDIN/STDOUT contract (verified)
|
||||
|
||||
> Hooks receive input via stdin as JSON and should return output via
|
||||
> stdout as JSON. Field names use camelCase.
|
||||
|
||||
Common stdin fields (every event):
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------------------|-----------------|-------------------------------------------------------|
|
||||
| `conversationId` | string | The unique UUID of the active agent conversation. |
|
||||
| `workspacePaths` | array<string> | Absolute directory paths of the user's workspaces. |
|
||||
| `transcriptPath` | string | Absolute path to the persistent `transcript.jsonl`. |
|
||||
| `artifactDirectoryPath` | string | Path to conversation artifacts and screenshots. |
|
||||
|
||||
`Stop` event additional fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------------|---------|----------------------------------------------------------------------------|
|
||||
| `executionNum` | integer | Sequence number of the execution attempt. |
|
||||
| `terminationReason` | string | `"model_stop"`, `"max_steps_exceeded"`, `"error"`, etc. |
|
||||
| `error` | string | Optional error message. |
|
||||
| `fullyIdle` | boolean | **Required.** True iff all background commands and async tasks are done. |
|
||||
|
||||
`Stop` event stdout (verified):
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|--------|-------------------------------------------------------------------------------------------------------|
|
||||
| `decision` | string | **Required.** Set to `"continue"` to FORCE the agent to keep running. Anything else allows the stop. |
|
||||
| `reason` | string | Optional. If `decision == "continue"`, injected as a system message. |
|
||||
|
||||
**CRITICAL**: emitting `{"decision": "continue"}` from a save hook would
|
||||
turn it into an infinite agent-loop trigger. The MemPalace save hook
|
||||
MUST emit `{}` on every code path. There is an explicit refusal in
|
||||
`mempal_save_hook_antigravity.sh` to ever print the literal word
|
||||
`"continue"` from a decision field.
|
||||
|
||||
`PreInvocation` event additional fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------------|---------|----------------------------------------------------------|
|
||||
| `invocationNum` | integer | Sequence number of the current model invocation. |
|
||||
| `initialNumSteps` | integer | Number of steps currently in the trajectory. |
|
||||
|
||||
`PreInvocation` event stdout (verified):
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------|----------------|-----------------------------------------------------------------------------------------------|
|
||||
| `injectSteps` | array<object> | Optional. Steps injected before the model is called. Each step has one of: |
|
||||
| | | `{ "toolCall": {...} }` / `{ "userMessage": "..." }` / `{ "ephemeralMessage": "..." }` |
|
||||
|
||||
We use `ephemeralMessage` for the wake-up injection: the message is
|
||||
visible to the model on this turn but does not persist to the
|
||||
transcript, so we do not pollute future model calls with the same
|
||||
injection.
|
||||
|
||||
`PreInvocation` fires before EVERY model invocation, not only at session
|
||||
start. We gate to `invocationNum == 1` to mimic Cursor's `sessionStart`
|
||||
semantics — exactly one wake injection per conversation.
|
||||
|
||||
### 1.5. Permissions — `https://antigravity.google/docs/permissions`
|
||||
|
||||
Permissions are user-side, configured via Allow / Deny / Ask lists.
|
||||
Plugins do **not** declare permissions in `plugin.json`. The
|
||||
third-party "antigravity-plugins" community skill at
|
||||
`~/.gemini/skills/antigravity-plugins/SKILL.md` documents a
|
||||
`"permissions": [...]` field in `plugin.json`; that field is fabricated
|
||||
and does not appear in any real Google-shipped plugin (`firebase`,
|
||||
`google-antigravity-sdk`, `chrome-devtools-plugin`,
|
||||
`modern-web-guidance-plugin`) inspected at
|
||||
`~/.gemini/config/plugins/`.
|
||||
|
||||
We ship a minimal `plugin.json` of `{"name": "mempalace"}`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Surfaces shipped
|
||||
|
||||
| Surface | What we ship |
|
||||
|---------------------------|---------------------------------------------------------------------------|
|
||||
| Plugin manifest | `.antigravity-plugin/plugin.json` — minimal, verified shape |
|
||||
| MCP auto-registration | `.antigravity-plugin/mcp_config.json` — registers `mempalace-mcp` stdio |
|
||||
| Skill | `.antigravity-plugin/skills/mempalace/SKILL.md` — real file, frontmatter |
|
||||
| `Stop` hook | `hooks/antigravity/mempal_save_hook_antigravity.sh` — counter + auto-mine |
|
||||
| `PreInvocation` hook | `hooks/antigravity/mempal_wake_hook_antigravity.sh` — wake injection |
|
||||
| Installer | `hooks/antigravity/install.sh` — idempotent, basename-match uninstall |
|
||||
| User-facing docs | `website/guide/antigravity.md` + sidebar wiring |
|
||||
| Examples | `examples/antigravity/{hooks.json,mcp_config.json,README.md}` |
|
||||
| Tests | 3 test files mirroring the cursor blueprint |
|
||||
|
||||
---
|
||||
|
||||
## 3. Surfaces deliberately not shipped
|
||||
|
||||
### 3.1. `PreCompact` equivalent — NOT SHIPPED
|
||||
|
||||
Antigravity's external `hooks.json` does **not** expose a context-compaction event. The Python SDK has an in-process `@hooks.on_compaction`
|
||||
decorator (see `~/.gemini/config/plugins/google-antigravity-sdk/examples/getting_started/hooks.md`),
|
||||
but that fires inside a Python `LocalAgentConfig`-built agent, not the
|
||||
IDE itself. There is no way to subscribe to compaction from a
|
||||
plugin's `hooks.json`.
|
||||
|
||||
UX consequence: long conversations can auto-compact without a save
|
||||
checkpoint. The `Stop` hook still catches the conversation when the
|
||||
user actually ends the turn, so the worst case is that some mid-turn
|
||||
state is lost on auto-compaction. Verbatim transcript ingestion via
|
||||
the `Stop` path covers the long-term recall use case.
|
||||
|
||||
### 3.2. Slash-commands / `commands/` — NOT SHIPPED
|
||||
|
||||
Antigravity has no `commands/` plugin component. The Cursor and Codex
|
||||
integrations both ship five quick-reference commands
|
||||
(`mempalace-help`, `-init`, `-mine`, `-search`, `-status`) that point
|
||||
at `mempalace instructions <cmd>`. Those have been folded into the
|
||||
`SKILL.md` `## Common operations` section so the agent gets the same
|
||||
quick-reference content via Antigravity's progressive-disclosure skill
|
||||
loading. No new files, no rule-noise, same discoverability.
|
||||
|
||||
### 3.3. `rules/` — NOT SHIPPED
|
||||
|
||||
`rules/<name>.md` files are evaluated as constraints on the agent's
|
||||
behavior. Shipping rules from MemPalace risks colliding with the
|
||||
user's existing project rules (e.g. `.agents/rules/*.md` files the
|
||||
user has already authored). Users who want strict MemPalace-related
|
||||
rules can drop them into their own `<workspace>/.agents/rules/`
|
||||
directory; we do not impose them.
|
||||
|
||||
### 3.4. Workspace-level `.agents/plugins/mempalace/` install — NOT SHIPPED BY DEFAULT
|
||||
|
||||
The installer writes to the global location at
|
||||
`~/.gemini/config/plugins/mempalace/`. Workspace-scoped installs are
|
||||
documented in `hooks/antigravity/README.md` for users who want to
|
||||
limit MemPalace to one workspace; they can `cp -r .antigravity-plugin
|
||||
<workspace>/.agents/plugins/mempalace`. We do not install there
|
||||
automatically because the canonical UX is global.
|
||||
|
||||
### 3.5. `permissions` field in `plugin.json` — NOT SHIPPED
|
||||
|
||||
Antigravity permissions are user-side (`Allow` / `Deny` / `Ask`
|
||||
lists). Plugin manifests do not declare permissions. The
|
||||
`"permissions": [...]` field documented in the third-party
|
||||
"antigravity-plugins" community skill is fabricated; no
|
||||
Google-shipped plugin uses it.
|
||||
|
||||
### 3.6. `PreToolUse` / `PostToolUse` hooks — NOT SHIPPED
|
||||
|
||||
These would let MemPalace observe every tool call (e.g.
|
||||
auto-extract entities after each `write_to_file`). Out of scope
|
||||
for v1; the hook surface is real and could be added in a future
|
||||
PR if there is demand. Documenting the omission here so a future
|
||||
contributor doesn't conclude the hooks weren't supported.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-checks against the user's running Antigravity
|
||||
|
||||
The user (`robs@`) has Antigravity 2.0 installed. The following live
|
||||
artifacts on this machine corroborate the published docs:
|
||||
|
||||
| Path | Confirms |
|
||||
|------------------------------------------------------------------------|----------------------------------------------------------------|
|
||||
| `~/.gemini/antigravity/mcp_config.json` | MCP config path + standard `mcpServers` shape |
|
||||
| `~/.gemini/antigravity/skills/<skill-name>/SKILL.md` (multiple) | Global skill discovery path |
|
||||
| `~/.gemini/config/plugins/firebase/plugin.json` | Real `plugin.json` shape (no `permissions` field) |
|
||||
| `~/.gemini/config/plugins/chrome-devtools-plugin/skills/.../SKILL.md` | In-plugin skill discovery `<plugin>/skills/<name>/SKILL.md` |
|
||||
| `~/.gemini/config/plugins/google-antigravity-sdk/examples/.../hooks.md` | SDK-side compaction hook is in-process Python only |
|
||||
| Existing `mempalace` entry in `mcp_config.json` | `mempalace-mcp` already running and discoverable |
|
||||
|
||||
---
|
||||
|
||||
## 5. Reference URLs (all 2026-05-27)
|
||||
|
||||
- `https://antigravity.google/docs/plugins`
|
||||
- `https://antigravity.google/docs/hooks?app=antigravity`
|
||||
- `https://antigravity.google/docs/skills`
|
||||
- `https://antigravity.google/docs/mcp`
|
||||
- `https://antigravity.google/docs/permissions`
|
||||
- `https://antigravity.google/docs/subagents`
|
||||
- `https://antigravity.google/blog/introducing-google-antigravity-sdk`
|
||||
@@ -0,0 +1,172 @@
|
||||
# MemPalace — Antigravity hook scripts
|
||||
|
||||
Lifecycle hooks for the [Antigravity IDE](https://antigravity.google/).
|
||||
|
||||
This is the third sibling of the Claude Code and Codex integrations
|
||||
(see `hooks/mempal_save_hook.sh` and `.codex-plugin/hooks/`). The
|
||||
overall shape is the same — a Stop event triggers a background save,
|
||||
a startup-time event injects memory into the agent — but the wire
|
||||
format and STDOUT contract are Antigravity-specific (see
|
||||
[STDIN_SHAPE.md](STDIN_SHAPE.md)).
|
||||
|
||||
## Quick start
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh
|
||||
```
|
||||
|
||||
This installs the plugin to `~/.gemini/config/plugins/mempalace/`.
|
||||
Restart Antigravity and the MCP server, skill, and hooks all register
|
||||
automatically.
|
||||
|
||||
To dry-run first:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh --dry-run
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh --uninstall
|
||||
```
|
||||
|
||||
## What gets installed
|
||||
|
||||
```
|
||||
~/.gemini/config/plugins/mempalace/
|
||||
├── plugin.json # marker manifest
|
||||
├── mcp_config.json # registers mempalace-mcp
|
||||
├── hooks.json # rendered from hooks.json.tmpl
|
||||
├── README.md
|
||||
├── skills/
|
||||
│ └── mempalace/
|
||||
│ └── SKILL.md
|
||||
└── hooks/
|
||||
├── lib/
|
||||
│ └── common.sh
|
||||
├── mempal_save_hook_antigravity.sh # Stop event handler
|
||||
└── mempal_wake_hook_antigravity.sh # PreInvocation handler
|
||||
```
|
||||
|
||||
`hooks.json` carries absolute paths to the two hook scripts (resolved
|
||||
from `__PLUGIN_DIR__` at install time).
|
||||
|
||||
## What the hooks do
|
||||
|
||||
### `mempal_save_hook_antigravity.sh` (Stop event)
|
||||
|
||||
Fires every time the agent's execution loop terminates. Increments a
|
||||
per-conversation counter; every `MEMPAL_SAVE_INTERVAL` fires (default
|
||||
15), spawns `mempalace mine <transcript-dir> --mode convos --wing
|
||||
<inferred>` in the background. The hook itself returns `{}` to stdout
|
||||
in under a few milliseconds — the actual mining runs detached and
|
||||
does not block the user.
|
||||
|
||||
Defers when:
|
||||
|
||||
- `fullyIdle == false` (background tasks still running)
|
||||
- `terminationReason == "error"` (transcript may be corrupt)
|
||||
- A previous save for this conversation is still running
|
||||
- Any kill switch is set
|
||||
|
||||
### `mempal_wake_hook_antigravity.sh` (PreInvocation event, gated)
|
||||
|
||||
Fires before every model invocation. Gated to `invocationNum == 1`
|
||||
(first invocation of the conversation only) — beyond that we'd be
|
||||
re-injecting on every turn. Calls `mempalace wake-up --wing <inferred>`
|
||||
with a 500ms hard timeout and emits the verbatim output as an
|
||||
`ephemeralMessage` so the agent sees relevant memory on its first
|
||||
response without polluting the persistent transcript.
|
||||
|
||||
Skips when:
|
||||
|
||||
- `invocationNum != 1`
|
||||
- Already woke this conversation (atomic `mkdir` loop guard)
|
||||
- `mempalace wake-up` exits non-zero, times out, or produces empty output
|
||||
- Any kill switch is set
|
||||
|
||||
## Kill switches
|
||||
|
||||
Any one of these disables both hooks (silent passthrough, exit 0):
|
||||
|
||||
| Knob | Value |
|
||||
|------------------------------------------|--------------------------------|
|
||||
| `MEMPAL_DISABLE_HOOK` | `1`, `true`, `yes` |
|
||||
| `MEMPALACE_HOOKS_AUTO_SAVE` | `false`, `0`, `no` |
|
||||
| `~/.mempalace/config.json` | `{"hooks": {"auto_save": false}}` |
|
||||
| Removing `~/.mempalace/` entirely | (palace nuke) |
|
||||
|
||||
## Workspace-scoped install (advanced)
|
||||
|
||||
If you want MemPalace to load only inside a specific workspace,
|
||||
manually copy the rendered plugin into your workspace's `.agents/plugins/`:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh --install-dir /tmp/render-stage
|
||||
mkdir -p <workspace>/.agents/plugins/
|
||||
cp -r /tmp/render-stage <workspace>/.agents/plugins/mempalace
|
||||
rm -rf /tmp/render-stage
|
||||
```
|
||||
|
||||
The global install at `~/.gemini/config/plugins/mempalace/` is the
|
||||
canonical UX and what we recommend.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hooks aren't firing
|
||||
|
||||
1. Confirm Antigravity sees the plugin: open the IDE, navigate to the
|
||||
Customizations page; `mempalace` should appear in the global plugins
|
||||
list.
|
||||
2. Check `~/.mempalace/hook_state/antigravity_hook.log` — every fire
|
||||
logs a line. No log lines = the hook is not being invoked.
|
||||
3. Verify `mempalace-mcp` is on `$PATH`: `mempalace-mcp --version`.
|
||||
4. Inspect the rendered `hooks.json` paths point at executable files:
|
||||
`bash -n ~/.gemini/config/plugins/mempalace/hooks/*.sh`.
|
||||
|
||||
### Save fires but no mining happens
|
||||
|
||||
1. Look for the most recent `[event=stop]` lines in
|
||||
`antigravity_hook.log` — `count` and `interval` should both be
|
||||
visible. Mining only triggers when `count % interval == 0`.
|
||||
2. Ensure a Python that can import `mempalace` is reachable. The hook
|
||||
runs `"$MEMPAL_PYTHON_BIN" -m mempalace`, where `MEMPAL_PYTHON_BIN`
|
||||
is resolved (in order) from `$MEMPAL_PYTHON`, the
|
||||
`mempalace-mcp` / `mempalace` console-script shebang on `$PATH`,
|
||||
then `python3`. A failed probe logs:
|
||||
|
||||
```
|
||||
ERROR: mempalace is not runnable via <python> -m mempalace; install mempalace or set MEMPAL_PYTHON
|
||||
```
|
||||
|
||||
On a GUI-launched Antigravity the harness `PATH` may differ from
|
||||
your shell `PATH`; if the shebang heuristic can't find the right
|
||||
interpreter, export `MEMPAL_PYTHON=/abs/path/python` (e.g.
|
||||
`"$(uv tool dir)/mempalace/bin/python"`) and restart.
|
||||
|
||||
### Wake injection isn't appearing
|
||||
|
||||
1. The wake hook only injects on `invocationNum == 1`. Subsequent
|
||||
invocations are gated.
|
||||
2. The atomic `mkdir` marker
|
||||
`~/.mempalace/hook_state/antigravity_woke_<conversationId>` exists
|
||||
after a successful injection. Remove it to re-inject (rare).
|
||||
3. `mempalace wake-up --wing <inferred>` may be returning empty output
|
||||
if the wing doesn't exist yet. Run `mempalace status` to verify
|
||||
wing presence.
|
||||
|
||||
## See also
|
||||
|
||||
- [INVESTIGATION.md](INVESTIGATION.md) — every Antigravity surface we
|
||||
investigated, with verbatim quotes and source URLs.
|
||||
- [STDIN_SHAPE.md](STDIN_SHAPE.md) — the exact wire format
|
||||
Antigravity uses, with worked examples.
|
||||
- [../mempal_save_hook.sh](../mempal_save_hook.sh) — Claude Code
|
||||
equivalent.
|
||||
- [../../.codex-plugin/hooks/](../../.codex-plugin/hooks/) — Codex
|
||||
equivalent.
|
||||
- [../../website/guide/antigravity.md](../../website/guide/antigravity.md)
|
||||
— full user-facing guide.
|
||||
@@ -0,0 +1,206 @@
|
||||
# Antigravity hook STDIN / STDOUT contract
|
||||
|
||||
This file documents the exact wire format the Antigravity IDE uses
|
||||
when invoking the MemPalace hook scripts. All fields are verbatim
|
||||
from Google's official Antigravity hooks documentation
|
||||
(`https://antigravity.google/docs/hooks?app=antigravity`, accessed
|
||||
2026-05-27). See [INVESTIGATION.md](INVESTIGATION.md) for the
|
||||
provenance audit.
|
||||
|
||||
## Wire format
|
||||
|
||||
Hooks receive **JSON on stdin** and must emit **JSON on stdout**.
|
||||
Field names are **camelCase**.
|
||||
|
||||
Hook execution timeout defaults to 30 seconds. The MemPalace plugin
|
||||
sets the Stop hook timeout to 30s and the PreInvocation hook timeout
|
||||
to 5s in the rendered `hooks.json`.
|
||||
|
||||
## Common stdin fields (every event)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------------------------|-----------------|--------------------------------------------------------|
|
||||
| `conversationId` | string | UUID of the active agent conversation. |
|
||||
| `workspacePaths` | array<string> | Absolute workspace dirs. **First element is canonical**. |
|
||||
| `transcriptPath` | string | Absolute path to `transcript.jsonl`. |
|
||||
| `artifactDirectoryPath` | string | Path to conversation artifacts and screenshots. |
|
||||
|
||||
## Stop event
|
||||
|
||||
### Stdin (additional fields)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---------------------|---------|--------------------------------------------------------------------------------------|
|
||||
| `executionNum` | integer | Sequence number of the execution attempt for this conversation. |
|
||||
| `terminationReason` | string | `"model_stop"`, `"max_steps_exceeded"`, `"error"`, etc. |
|
||||
| `error` | string | Optional. Set when termination was caused by a system error. |
|
||||
| `fullyIdle` | boolean | **Required.** True iff all background commands and async tasks have completed. |
|
||||
|
||||
### Stdout
|
||||
|
||||
| Field | Type | Notes |
|
||||
|------------|--------|--------------------------------------------------------------------------------------------------------|
|
||||
| `decision` | string | If `"continue"`, **forces** the agent to keep running. Anything else allows the stop. |
|
||||
| `reason` | string | Optional. If `decision == "continue"`, injected as a system message into the conversation. |
|
||||
|
||||
**MemPalace policy**: the save hook ALWAYS emits `{}` and exits 0. It
|
||||
NEVER emits `{"decision": "continue"}` — that would force an infinite
|
||||
agent loop. There is an explicit refusal in
|
||||
`mempal_save_hook_antigravity.sh` to ever construct a stdout JSON
|
||||
object containing the literal word `"continue"` in a decision field.
|
||||
|
||||
### MemPalace gating
|
||||
|
||||
The save hook short-circuits with `{}` (no save triggered) when ANY
|
||||
of the following hold:
|
||||
|
||||
1. `MEMPAL_DISABLE_HOOK=1` (or `true`/`yes`) is set.
|
||||
2. `MEMPALACE_HOOKS_AUTO_SAVE=false` (or `0`/`no`) is set.
|
||||
3. `~/.mempalace/config.json` has `hooks.auto_save: false`.
|
||||
4. `~/.mempalace/` directory does not exist (user nuked the palace).
|
||||
5. Stdin is malformed or empty (sentinel-guarded parse failure).
|
||||
6. `fullyIdle == false` (background tasks still running; defer save).
|
||||
7. `terminationReason == "error"` (transcript may be corrupt).
|
||||
8. `transcriptPath` validation fails (not a `.json`/`.jsonl`, or `..` traversal).
|
||||
9. The transcript file does not exist on disk.
|
||||
10. The save counter has not yet hit `count % MEMPAL_SAVE_INTERVAL == 0`.
|
||||
11. A pending save is still running for this conversation (less than 1 hour old).
|
||||
12. The `mempalace` CLI is not on `$PATH`.
|
||||
|
||||
When the modulo gate is hit and validation passes, the hook spawns
|
||||
`mempalace mine <transcript-dir> --mode convos --wing <inferred>` in
|
||||
the background and returns `{}` immediately.
|
||||
|
||||
## PreInvocation event
|
||||
|
||||
### Stdin (additional fields)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------------------|---------|------------------------------------------------------------------|
|
||||
| `invocationNum` | integer | Sequence number of the current model invocation (1-based). |
|
||||
| `initialNumSteps` | integer | Number of steps currently in the trajectory. |
|
||||
|
||||
### Stdout
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---------------|----------------|--------------------------------------------------------------------------------------------------------|
|
||||
| `injectSteps` | array<object> | Optional. Steps to inject before the model is called. Each step has one of: `{"toolCall": {...}}`, `{"userMessage": "..."}`, `{"ephemeralMessage": "..."}` |
|
||||
|
||||
The `ephemeralMessage` form is what the MemPalace wake hook emits — it
|
||||
delivers the wake-up text to the model on this turn but does not
|
||||
persist into the transcript, so subsequent invocations don't see a
|
||||
duplicate.
|
||||
|
||||
### MemPalace gating
|
||||
|
||||
The wake hook short-circuits with `{}` (no injection) when ANY of:
|
||||
|
||||
1. Any kill switch trips (same five conditions as the save hook).
|
||||
2. `invocationNum != 1` — we only inject on the first model call of
|
||||
each conversation, mimicking Cursor's `sessionStart` semantics.
|
||||
3. The atomic `mkdir`-based loop guard is already taken (this
|
||||
conversation already received a wake injection).
|
||||
4. `mempalace wake-up --wing <inferred>` exits non-zero, times out
|
||||
(500ms hard cap), or produces empty output.
|
||||
|
||||
When the gates pass and `mempalace wake-up` returns text, the hook
|
||||
emits:
|
||||
|
||||
```json
|
||||
{
|
||||
"injectSteps": [
|
||||
{ "ephemeralMessage": "<verbatim wake-up output>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The wake hook NEVER emits a `decision` field — that field belongs to
|
||||
the Stop event. There is a final guard against any stdout that
|
||||
contains a `decision` key.
|
||||
|
||||
## Worked example: Stop event
|
||||
|
||||
### Input
|
||||
|
||||
```json
|
||||
{
|
||||
"executionNum": 1,
|
||||
"terminationReason": "model_stop",
|
||||
"error": "",
|
||||
"fullyIdle": true,
|
||||
"conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587",
|
||||
"workspacePaths": ["/home/me/projects/mempalace"],
|
||||
"transcriptPath": "/home/me/projects/mempalace/.gemini/jetski/transcript.jsonl",
|
||||
"artifactDirectoryPath": "/home/me/projects/mempalace/.gemini/jetski/artifacts"
|
||||
}
|
||||
```
|
||||
|
||||
### Output (always)
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
(Side effects: counter `~/.mempalace/hook_state/antigravity_save_count_<id>` is
|
||||
incremented; if the modulo gate fires, a background `mempalace mine`
|
||||
subprocess is spawned with the transcript directory and the inferred
|
||||
wing `wing_mempalace`.)
|
||||
|
||||
## Worked example: PreInvocation, first invocation
|
||||
|
||||
### Input
|
||||
|
||||
```json
|
||||
{
|
||||
"invocationNum": 1,
|
||||
"initialNumSteps": 0,
|
||||
"conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587",
|
||||
"workspacePaths": ["/home/me/projects/mempalace"],
|
||||
"transcriptPath": "/home/me/projects/mempalace/.gemini/jetski/transcript.jsonl",
|
||||
"artifactDirectoryPath": "/home/me/projects/mempalace/.gemini/jetski/artifacts"
|
||||
}
|
||||
```
|
||||
|
||||
### Output (when the palace has memory for `wing_mempalace`)
|
||||
|
||||
```json
|
||||
{
|
||||
"injectSteps": [
|
||||
{
|
||||
"ephemeralMessage": "<exact verbatim text from `mempalace wake-up --wing wing_mempalace`>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Output (when invocationNum != 1, or any gate trips)
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
## State files
|
||||
|
||||
All hook state lives under `~/.mempalace/hook_state/` (overridable
|
||||
via `$MEMPAL_STATE_DIR`) and is namespaced with the `antigravity_`
|
||||
prefix to coexist with Claude Code, Cursor, and Codex hook state in
|
||||
the same directory.
|
||||
|
||||
| File | Purpose |
|
||||
|-----------------------------------------------|-----------------------------------------------|
|
||||
| `antigravity_hook.log` | All hook activity, ISO8601Z timestamps. |
|
||||
| `antigravity_save_count_<conversationId>` | Per-conversation Stop counter. |
|
||||
| `antigravity_pending_<conversationId>` | Marker file for in-flight save subprocess. |
|
||||
| `antigravity_woke_<conversationId>` (dir) | Atomic mkdir marker for wake injection. |
|
||||
| `antigravity_last_input.log` | 4 KB cap, mode 0600, set on parse failure. |
|
||||
| `antigravity_last_python_err.log` | Python stderr from the JSON parser, mode 0600.|
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|--------------------------------|----------------------|-----------------------------------------------------------|
|
||||
| `MEMPAL_PYTHON` | `$(command -v python3)` | Override the Python interpreter used by the hooks. |
|
||||
| `MEMPAL_STATE_DIR` | `~/.mempalace/hook_state` | Override the hook state directory. |
|
||||
| `MEMPAL_SAVE_INTERVAL` | `15` | Save every Nth Stop fire. Floored to >= 1 (no /0). |
|
||||
| `MEMPAL_DISABLE_HOOK` | unset | Set to `1` / `true` / `yes` to disable both hooks. |
|
||||
| `MEMPALACE_HOOKS_AUTO_SAVE` | unset | Set to `false` / `0` / `no` to disable both hooks. |
|
||||
Executable
+357
@@ -0,0 +1,357 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE ANTIGRAVITY INSTALLER
|
||||
#
|
||||
# Idempotent installer for the Antigravity plugin. Copies
|
||||
# .antigravity-plugin/* and hooks/antigravity/{lib,*.sh} into the
|
||||
# install directory (default ~/.gemini/config/plugins/mempalace/),
|
||||
# renders hooks.json.tmpl into hooks.json with absolute paths, and
|
||||
# leaves the result in a state Antigravity will discover on next
|
||||
# launch.
|
||||
#
|
||||
# === Usage ===
|
||||
#
|
||||
# bash hooks/antigravity/install.sh # install with defaults
|
||||
# bash hooks/antigravity/install.sh --dry-run # show what would happen
|
||||
# bash hooks/antigravity/install.sh --uninstall # remove plugin
|
||||
# bash hooks/antigravity/install.sh --install-dir <p> # custom install dir
|
||||
# bash hooks/antigravity/install.sh --log-level debug # noisier output
|
||||
#
|
||||
# === Idempotency ===
|
||||
#
|
||||
# Re-running the installer produces a byte-identical install dir.
|
||||
# Files are only written when their content differs from what is
|
||||
# already on disk (cmp gate). The user's ~/.gemini/config/plugins/
|
||||
# directory is never touched outside the mempalace/ subdirectory.
|
||||
#
|
||||
# === Uninstall safety ===
|
||||
#
|
||||
# Uninstall removes the install dir entirely IFF it is the
|
||||
# mempalace/ plugin directory. We match by basename of the install
|
||||
# dir, never by substring search, so a user who has a sibling plugin
|
||||
# at ~/.gemini/config/plugins/mempalace-foo/ is unaffected.
|
||||
#
|
||||
# === set -e ===
|
||||
#
|
||||
# Installer can use `set -e` (constraint #2 only forbids it in the
|
||||
# hook scripts themselves). On any error we exit non-zero so a CI run
|
||||
# fails loudly.
|
||||
|
||||
set -e
|
||||
set -u
|
||||
|
||||
# ── Repo root resolution ─────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
|
||||
PLUGIN_SRC="$REPO_ROOT/.antigravity-plugin"
|
||||
HOOKS_SRC="$REPO_ROOT/hooks/antigravity"
|
||||
|
||||
# ── Defaults ─────────────────────────────────────────────────────────
|
||||
INSTALL_DIR_DEFAULT="$HOME/.gemini/config/plugins/mempalace"
|
||||
INSTALL_DIR=""
|
||||
DRY_RUN=0
|
||||
UNINSTALL=0
|
||||
LOG_LEVEL="info"
|
||||
|
||||
# ── Args ─────────────────────────────────────────────────────────────
|
||||
print_usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: install.sh [--install-dir DIR] [--dry-run] [--uninstall] [--log-level LEVEL]
|
||||
|
||||
Options:
|
||||
--install-dir DIR Plugin install directory.
|
||||
Default: ~/.gemini/config/plugins/mempalace
|
||||
--dry-run Show what would happen without writing anything.
|
||||
--uninstall Remove the installed plugin.
|
||||
--log-level LEVEL debug | info | warn | error. Default: info.
|
||||
-h, --help Show this help.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--install-dir)
|
||||
INSTALL_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--install-dir=*)
|
||||
INSTALL_DIR="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
--uninstall)
|
||||
UNINSTALL=1
|
||||
shift
|
||||
;;
|
||||
--log-level)
|
||||
LOG_LEVEL="${2:-info}"
|
||||
shift 2
|
||||
;;
|
||||
--log-level=*)
|
||||
LOG_LEVEL="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
print_usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: unknown argument: $1" >&2
|
||||
print_usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$INSTALL_DIR" ]; then
|
||||
INSTALL_DIR="$INSTALL_DIR_DEFAULT"
|
||||
fi
|
||||
|
||||
# ── Absolutize the install dir ───────────────────────────────────────
|
||||
#
|
||||
# The cursor PR review caught that a relative --install-dir would get
|
||||
# baked into hooks.json verbatim, leaving paths like
|
||||
# `./plugins/.../mempal_save_hook_antigravity.sh` that Antigravity
|
||||
# can't resolve at runtime. Absolutize before writing anything.
|
||||
mempal_absolutize() {
|
||||
local p="$1"
|
||||
case "$p" in
|
||||
/*) printf '%s' "$p" ;;
|
||||
~*) printf '%s' "${p/#\~/$HOME}" ;;
|
||||
*)
|
||||
# Resolve relative to the user's $PWD at invocation time.
|
||||
# We never `cd` in the main shell of this installer, so
|
||||
# $PWD is already the user's invocation directory — no
|
||||
# subshell cd dance needed.
|
||||
local base="${PWD}"
|
||||
printf '%s/%s' "$base" "$p"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
INSTALL_DIR="$(mempal_absolutize "$INSTALL_DIR")"
|
||||
# Squash any `//` or `./` or `name/..` artefacts using Python's
|
||||
# os.path.normpath; falls back to the raw value if Python is missing
|
||||
# (which would be very unusual on macOS / Linux).
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
INSTALL_DIR="$(python3 -c 'import os,sys; print(os.path.normpath(sys.argv[1]))' "$INSTALL_DIR")"
|
||||
fi
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────────
|
||||
log() {
|
||||
local lvl="$1"; shift
|
||||
local msg="$*"
|
||||
case "$lvl" in
|
||||
debug)
|
||||
[ "$LOG_LEVEL" = "debug" ] && echo "[install] DEBUG: $msg"
|
||||
return 0
|
||||
;;
|
||||
info)
|
||||
case "$LOG_LEVEL" in
|
||||
debug|info) echo "[install] $msg" ;;
|
||||
esac
|
||||
;;
|
||||
warn)
|
||||
case "$LOG_LEVEL" in
|
||||
debug|info|warn) echo "[install] WARN: $msg" >&2 ;;
|
||||
esac
|
||||
;;
|
||||
error)
|
||||
echo "[install] ERROR: $msg" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Action helpers (dry-run aware) ──────────────────────────────────
|
||||
run() {
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "[install] DRY-RUN: $*"
|
||||
return 0
|
||||
fi
|
||||
"$@"
|
||||
}
|
||||
|
||||
# ── Render template ──────────────────────────────────────────────────
|
||||
#
|
||||
# Substitutes __PLUGIN_DIR__ in $src into $dst with $INSTALL_DIR.
|
||||
# Emits the rendered file to a temp path first, then promotes it iff
|
||||
# the content differs from what's already at $dst. The cmp gate is
|
||||
# what makes the installer idempotent: a no-op re-run produces no
|
||||
# disk writes (and the test suite asserts byte-equality).
|
||||
render_template() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
if [ ! -f "$src" ]; then
|
||||
log error "template not found: $src"
|
||||
return 1
|
||||
fi
|
||||
local tmp
|
||||
tmp="$(mktemp "${TMPDIR:-/tmp}/mempal_agy_render.XXXXXX")"
|
||||
# Python over awk/sed — INSTALL_DIR may legitimately contain
|
||||
# characters (spaces, colons) that would require careful escaping
|
||||
# in a sed s/// replacement. Python read+replace handles all of
|
||||
# them uniformly.
|
||||
python3 -c "
|
||||
import sys
|
||||
src, dst, install_dir = sys.argv[1:4]
|
||||
with open(src, 'r') as f:
|
||||
body = f.read()
|
||||
body = body.replace('__PLUGIN_DIR__', install_dir)
|
||||
with open(dst, 'w') as f:
|
||||
f.write(body)
|
||||
" "$src" "$tmp" "$INSTALL_DIR"
|
||||
if [ -f "$dst" ] && cmp -s "$tmp" "$dst"; then
|
||||
rm -f "$tmp"
|
||||
log debug "unchanged: $dst"
|
||||
return 0
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "[install] DRY-RUN: would render $src -> $dst"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
mv "$tmp" "$dst"
|
||||
log info "wrote: $dst"
|
||||
}
|
||||
|
||||
# ── copy_file: cmp-gated copy that preserves mode ────────────────────
|
||||
copy_file() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
if [ ! -f "$src" ]; then
|
||||
log error "missing source file: $src"
|
||||
return 1
|
||||
fi
|
||||
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
|
||||
log debug "unchanged: $dst"
|
||||
return 0
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "[install] DRY-RUN: would copy $src -> $dst"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
cp "$src" "$dst"
|
||||
log info "wrote: $dst"
|
||||
}
|
||||
|
||||
# ── Uninstall path ───────────────────────────────────────────────────
|
||||
#
|
||||
# We DO NOT remove the install dir by string-substring match against
|
||||
# the path. We require the install dir's basename to be exactly
|
||||
# "mempalace" — that way an unrelated sibling like
|
||||
# ~/.gemini/config/plugins/mempalace-foo/ is left alone, and a
|
||||
# malformed --install-dir like ~ or / cannot wipe the user's home.
|
||||
do_uninstall() {
|
||||
local base
|
||||
base="$(basename "$INSTALL_DIR")"
|
||||
if [ "$base" != "mempalace" ]; then
|
||||
log error "refusing to uninstall: install dir basename is '$base', expected 'mempalace'"
|
||||
log error "(safety guard: prevents accidental wipe of unrelated directories)"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
log info "nothing to uninstall: $INSTALL_DIR does not exist"
|
||||
return 0
|
||||
fi
|
||||
# Verify the dir LOOKS like our plugin before removing — a
|
||||
# plugin.json file with our marker is the proof.
|
||||
if [ ! -f "$INSTALL_DIR/plugin.json" ]; then
|
||||
log error "refusing to uninstall: $INSTALL_DIR has no plugin.json"
|
||||
return 1
|
||||
fi
|
||||
if ! grep -q '"name"[[:space:]]*:[[:space:]]*"mempalace"' "$INSTALL_DIR/plugin.json" 2>/dev/null; then
|
||||
log error "refusing to uninstall: $INSTALL_DIR/plugin.json is not a mempalace plugin"
|
||||
return 1
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "[install] DRY-RUN: would rm -rf $INSTALL_DIR"
|
||||
return 0
|
||||
fi
|
||||
rm -rf "$INSTALL_DIR"
|
||||
log info "uninstalled: $INSTALL_DIR"
|
||||
}
|
||||
|
||||
if [ "$UNINSTALL" -eq 1 ]; then
|
||||
do_uninstall
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Pre-install sanity ───────────────────────────────────────────────
|
||||
if [ ! -d "$PLUGIN_SRC" ]; then
|
||||
log error "missing source: $PLUGIN_SRC"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$HOOKS_SRC" ]; then
|
||||
log error "missing source: $HOOKS_SRC"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Soft-check that mempalace-mcp is on PATH; warn but do not fail.
|
||||
if ! command -v mempalace-mcp >/dev/null 2>&1; then
|
||||
log warn "mempalace-mcp is not on PATH; the MCP server will fail to start until it is."
|
||||
log warn " fix: 'uv tool install mempalace' or 'pip install mempalace'"
|
||||
fi
|
||||
|
||||
# Soft-check ~/.gemini exists; if missing, Antigravity isn't installed.
|
||||
if [ ! -d "$HOME/.gemini" ]; then
|
||||
log warn "$HOME/.gemini not found — Antigravity is probably not installed yet."
|
||||
log warn " the install will still proceed; Antigravity will pick up the plugin on first launch."
|
||||
fi
|
||||
|
||||
log info "install dir: $INSTALL_DIR"
|
||||
|
||||
# ── Install: directories ─────────────────────────────────────────────
|
||||
run mkdir -p "$INSTALL_DIR" \
|
||||
"$INSTALL_DIR/skills/mempalace" \
|
||||
"$INSTALL_DIR/skills/mempalace-recall" \
|
||||
"$INSTALL_DIR/rules" \
|
||||
"$INSTALL_DIR/hooks" \
|
||||
"$INSTALL_DIR/hooks/lib"
|
||||
|
||||
# ── Install: plugin metadata ─────────────────────────────────────────
|
||||
copy_file "$PLUGIN_SRC/plugin.json" "$INSTALL_DIR/plugin.json"
|
||||
copy_file "$PLUGIN_SRC/mcp_config.json" "$INSTALL_DIR/mcp_config.json"
|
||||
copy_file "$PLUGIN_SRC/README.md" "$INSTALL_DIR/README.md"
|
||||
|
||||
# ── Install: skills (real files, no symlinks at the discovery path) ──
|
||||
copy_file "$PLUGIN_SRC/skills/mempalace/SKILL.md" \
|
||||
"$INSTALL_DIR/skills/mempalace/SKILL.md"
|
||||
copy_file "$PLUGIN_SRC/skills/mempalace-recall/SKILL.md" \
|
||||
"$INSTALL_DIR/skills/mempalace-recall/SKILL.md"
|
||||
|
||||
# ── Install: optional recall rule ────────────────────────────────────
|
||||
#
|
||||
# Antigravity discovers markdown rules under the plugin's rules/
|
||||
# directory. This one is recall-only and intentionally lightweight —
|
||||
# it complements the mempalace-recall skill. Shipping it as a plugin
|
||||
# rule (not an always-on global rule) keeps it scoped to recall-
|
||||
# relevant turns, honouring MemPalace's "memory should feel instant"
|
||||
# budget.
|
||||
copy_file "$PLUGIN_SRC/rules/mempalace-recall.md" \
|
||||
"$INSTALL_DIR/rules/mempalace-recall.md"
|
||||
|
||||
# ── Install: hooks ───────────────────────────────────────────────────
|
||||
copy_file "$HOOKS_SRC/lib/common.sh" "$INSTALL_DIR/hooks/lib/common.sh"
|
||||
copy_file "$HOOKS_SRC/mempal_save_hook_antigravity.sh" "$INSTALL_DIR/hooks/mempal_save_hook_antigravity.sh"
|
||||
copy_file "$HOOKS_SRC/mempal_wake_hook_antigravity.sh" "$INSTALL_DIR/hooks/mempal_wake_hook_antigravity.sh"
|
||||
|
||||
# Ensure hook scripts are executable on the install side. cp preserves
|
||||
# mode but a fresh git clone from a tarball might not — chmod is
|
||||
# defensive, idempotent, and bash 3.2 safe.
|
||||
if [ "$DRY_RUN" -ne 1 ]; then
|
||||
chmod 755 "$INSTALL_DIR/hooks/mempal_save_hook_antigravity.sh" 2>/dev/null || true
|
||||
chmod 755 "$INSTALL_DIR/hooks/mempal_wake_hook_antigravity.sh" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Install: render hooks.json from template ─────────────────────────
|
||||
render_template "$PLUGIN_SRC/hooks.json.tmpl" "$INSTALL_DIR/hooks.json"
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
log info "DRY-RUN complete; no files written."
|
||||
else
|
||||
log info "install complete: $INSTALL_DIR"
|
||||
log info "restart Antigravity to load the plugin."
|
||||
fi
|
||||
@@ -0,0 +1,498 @@
|
||||
# shellcheck shell=bash
|
||||
# MEMPALACE ANTIGRAVITY HOOK — shared helpers
|
||||
#
|
||||
# Sourced by the two Antigravity hook scripts:
|
||||
# * mempal_save_hook_antigravity.sh (Stop event)
|
||||
# * mempal_wake_hook_antigravity.sh (PreInvocation event, gated to invocationNum==1)
|
||||
#
|
||||
# Mirrors the conventions of the existing Claude Code hook scripts
|
||||
# (hooks/mempal_save_hook.sh, hooks/mempal_precompact_hook.sh):
|
||||
#
|
||||
# * STATE_DIR layout under ~/.mempalace/hook_state/
|
||||
# * MEMPAL_PYTHON resolution order (override -> $PATH -> bare python3)
|
||||
# * MEMPALACE_HOOKS_AUTO_SAVE=false kill switch (config.json fallback)
|
||||
# * sentinel-guarded Python parser via `sed -n 'Np'` (bash 3.2 safe)
|
||||
# * fail-open on internal errors: emit valid JSON and log, never crash
|
||||
# the hook host
|
||||
#
|
||||
# Antigravity-specific contract differences from Claude / Cursor:
|
||||
#
|
||||
# * Antigravity stdin uses camelCase (transcriptPath, conversationId,
|
||||
# workspacePaths, executionNum, terminationReason, fullyIdle,
|
||||
# invocationNum, initialNumSteps), not the snake_case Claude Code
|
||||
# format (session_id, transcript_path, stop_hook_active).
|
||||
# * Antigravity stdout for Stop event MUST be {} on every success path
|
||||
# because { "decision": "continue" } would force the agent into an
|
||||
# infinite re-execution loop. The save hook explicitly refuses to
|
||||
# ever emit the "continue" decision.
|
||||
# * Antigravity stdout for PreInvocation can carry an "injectSteps"
|
||||
# array of { "ephemeralMessage": "..." } objects to inject memory
|
||||
# into the agent's first turn.
|
||||
#
|
||||
# This file is sourced, not executed, so it intentionally has no
|
||||
# shebang. The `# shellcheck shell=bash` directive above tells
|
||||
# shellcheck to treat it as bash when run standalone.
|
||||
|
||||
# bash 3.2.57 (the macOS default) is the lower bound. Do not use
|
||||
# `mapfile`, `readarray`, `declare -A`, or `${var^^}` — none of those
|
||||
# exist in 3.2. Use `sed -n 'Np'` for line extraction and case-folding
|
||||
# via `tr` instead.
|
||||
|
||||
# ── State directory + log path ────────────────────────────────────────
|
||||
#
|
||||
# Honour MEMPAL_STATE_DIR while keeping the default identical to the
|
||||
# Claude Code hooks so a user running both keeps a single state directory
|
||||
# (constraint #7 in the integration brief).
|
||||
MEMPAL_STATE_DIR="${MEMPAL_STATE_DIR:-$HOME/.mempalace/hook_state}"
|
||||
mkdir -p "$MEMPAL_STATE_DIR" 2>/dev/null
|
||||
MEMPAL_AGY_LOG="$MEMPAL_STATE_DIR/antigravity_hook.log"
|
||||
|
||||
# ── Python interpreter resolution ─────────────────────────────────────
|
||||
#
|
||||
# The hooks run mempalace as `"$MEMPAL_PYTHON_BIN" -m mempalace`, so the
|
||||
# resolved interpreter MUST be one that has the mempalace package
|
||||
# importable. The single most common install path —
|
||||
# `uv tool install mempalace` (and `pipx install`) — puts the
|
||||
# `mempalace` / `mempalace-mcp` *console scripts* on PATH inside an
|
||||
# ISOLATED environment whose interpreter is NOT the system `python3`.
|
||||
# So naively resolving `command -v python3` lands on a system Python
|
||||
# that can't import mempalace, the `-m mempalace` probe fails, and
|
||||
# mining silently never fires. (This bit a real user on PR #1633.)
|
||||
#
|
||||
# Resolution order — first hit wins:
|
||||
# 1. $MEMPAL_PYTHON — explicit operator override
|
||||
# 2. shebang of the mempalace-mcp / mempalace console script on PATH
|
||||
# — pip/uv write these with an absolute-path shebang pointing at
|
||||
# the exact interpreter that owns the package. This is the SAME
|
||||
# console script mcp_config.json launches, so if the MCP server
|
||||
# can start, the hooks resolve a working interpreter too — no
|
||||
# MEMPAL_PYTHON needed for the common install paths.
|
||||
# 3. $(command -v python3) — an activated dev venv /
|
||||
# editable install where python3 itself owns the package
|
||||
# 4. bare "python3" — last-resort fallback
|
||||
#
|
||||
# Steps 2-4 are pure string parsing + stat (no Python subprocess), so
|
||||
# resolution stays cheap enough to run at source time on every hook
|
||||
# fire, including gated-out / kill-switched ones. We deliberately do
|
||||
# NOT run an `import mempalace` probe here: building that import pays
|
||||
# the chromadb/onnx cold-start cost, which a recent perf fix
|
||||
# (df295bd) moved OFF the hook foreground on purpose. The downstream
|
||||
# `-m mempalace --version` probe (backgrounded in the save hook,
|
||||
# subprocessed in the wake hook) is the safety net that catches a
|
||||
# shebang interpreter whose package is genuinely broken.
|
||||
mempal_resolve_python() {
|
||||
# 1. Explicit override always wins.
|
||||
local p="${MEMPAL_PYTHON:-}"
|
||||
if [ -n "$p" ] && [ -x "$p" ]; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 2. Derive the interpreter from a mempalace console-script shebang.
|
||||
local script_path shebang interp
|
||||
for script_path in mempalace-mcp mempalace; do
|
||||
script_path="$(command -v "$script_path" 2>/dev/null || true)"
|
||||
[ -n "$script_path" ] || continue
|
||||
[ -r "$script_path" ] || continue
|
||||
shebang="$(sed -n '1p' "$script_path" 2>/dev/null)"
|
||||
case "$shebang" in
|
||||
'#!'*)
|
||||
interp="${shebang#\#!}" # drop the leading '#!'
|
||||
interp="${interp%$'\r'}" # strip a trailing CR (CRLF files)
|
||||
interp="${interp# }" # drop one leading space
|
||||
interp="${interp%% *}" # first whitespace-delimited token
|
||||
# Guard against `#!/usr/bin/env python` wrappers: only
|
||||
# accept a token whose basename looks like a Python
|
||||
# interpreter and is executable. An `env`-style shebang
|
||||
# yields `/usr/bin/env` here, which we skip.
|
||||
case "${interp##*/}" in
|
||||
python*)
|
||||
if [ -x "$interp" ]; then
|
||||
printf '%s' "$interp"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 3. First python3 on PATH.
|
||||
p="$(command -v python3 2>/dev/null || true)"
|
||||
if [ -n "$p" ]; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 4. Last-resort bare name.
|
||||
printf '%s' "python3"
|
||||
}
|
||||
MEMPAL_PYTHON_BIN="$(mempal_resolve_python)"
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# ISO8601Z timestamps are greppable across timezones.
|
||||
mempal_log() {
|
||||
local event="${1:-?}"
|
||||
local conv="${2:-unknown}"
|
||||
local msg="${3:-}"
|
||||
local ts
|
||||
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
printf '[%s] [event=%s] [conv=%s] %s\n' "$ts" "$event" "$conv" "$msg" \
|
||||
>> "$MEMPAL_AGY_LOG" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Kill switch ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Disabled if ANY of:
|
||||
# * MEMPAL_DISABLE_HOOK is a truthy string
|
||||
# * MEMPALACE_HOOKS_AUTO_SAVE is false/0/no
|
||||
# * ~/.mempalace/config.json sets hooks.auto_save: false
|
||||
# * ~/.mempalace/ directory does not exist (user nuked the palace)
|
||||
#
|
||||
# Returns 0 (kill switch tripped, hook should short-circuit) or non-zero
|
||||
# (proceed normally).
|
||||
mempal_kill_switch_tripped() {
|
||||
# Palace nuke is the strongest signal: respect it before touching
|
||||
# disk for state, logging, etc.
|
||||
if [ ! -d "$HOME/.mempalace" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "${MEMPAL_DISABLE_HOOK:-}" in
|
||||
1|true|TRUE|yes|YES) return 0 ;;
|
||||
esac
|
||||
|
||||
case "${MEMPALACE_HOOKS_AUTO_SAVE:-}" in
|
||||
false|FALSE|0|no|NO) return 0 ;;
|
||||
esac
|
||||
|
||||
local cfg="$HOME/.mempalace/config.json"
|
||||
if [ -f "$cfg" ]; then
|
||||
local auto
|
||||
auto=$("$MEMPAL_PYTHON_BIN" -c "
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
cfg = json.load(f)
|
||||
print(str(cfg.get('hooks', {}).get('auto_save', True)).lower())
|
||||
except Exception:
|
||||
print('true')
|
||||
" "$cfg" 2>/dev/null)
|
||||
if [ "$auto" = "false" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── camelCase JSON parser (Antigravity stdin) ────────────────────────
|
||||
#
|
||||
# Reads JSON from stdin once and prints a sanitized, sentinel-bracketed
|
||||
# block of fields the bash side can grab via `sed -n 'Np'`. Why a
|
||||
# sentinel and per-line layout: bash 3.2 doesn't have `mapfile` or
|
||||
# `readarray`, and `eval`-on-shell-var is the wrong shape (every value
|
||||
# is user-controllable JSON). Sentinel + line offset is the same pattern
|
||||
# the existing Claude Code hook (hooks/mempal_save_hook.sh) uses.
|
||||
#
|
||||
# Output layout (one field per line; line numbers are stable and the
|
||||
# fields are documented in STDIN_SHAPE.md):
|
||||
#
|
||||
# line 1: __MEMPAL_PARSE_OK__ — sentinel (parse success marker)
|
||||
# line 2: conversationId — sanitized to [A-Za-z0-9._-]
|
||||
# line 3: transcriptPath — sanitized to a safe path charset
|
||||
# line 4: workspacePath — workspacePaths[0], sanitized
|
||||
# line 5: artifactDirectoryPath — sanitized
|
||||
# line 6: executionNum — integer, default 0
|
||||
# line 7: terminationReason — sanitized to [a-z_]
|
||||
# line 8: fullyIdle — "True" or "False" (string)
|
||||
# line 9: invocationNum — integer, default 0
|
||||
# line 10: initialNumSteps — integer, default 0
|
||||
#
|
||||
# The sanitizers are defense-in-depth: every field is also vetted by
|
||||
# the Python json.load step, but we still strip shell-meaningful chars
|
||||
# from any field a downstream bash variable might interpolate, so that
|
||||
# a hostile / malformed harness payload cannot inject command tokens.
|
||||
#
|
||||
# Stderr from Python is captured to last_python_err.log at mode 0600 so
|
||||
# operators can debug parse failures without re-firing the hook. The
|
||||
# umask 077 on the inner subshell creates the file at 0600 atomically;
|
||||
# the explicit chmod 600 below is a belt-and-suspenders guard if a
|
||||
# future edit ever drops the umask.
|
||||
mempal_parse_stdin() {
|
||||
local input="$1"
|
||||
(
|
||||
umask 077
|
||||
printf '%s' "$input" | "$MEMPAL_PYTHON_BIN" -c "
|
||||
import sys, json, re
|
||||
|
||||
# IMPORTANT: do NOT wrap json.load in a try/except. If the input is
|
||||
# not valid JSON we want Python to exit non-zero BEFORE printing the
|
||||
# __MEMPAL_PARSE_OK__ sentinel — the bash caller looks for the
|
||||
# sentinel on line 1 to decide whether to engage its defense-in-depth
|
||||
# 'failed to parse' branch. Catching the exception and falling back
|
||||
# to data={} would let the sentinel print, masking parse failures
|
||||
# from the bash side. The traceback lands in
|
||||
# antigravity_last_python_err.log so operators can debug.
|
||||
data = json.load(sys.stdin)
|
||||
|
||||
def safe(s, allowed=r'[^a-zA-Z0-9_/.\-~]'):
|
||||
return re.sub(allowed, '', str(s))
|
||||
|
||||
def safe_id(s):
|
||||
return re.sub(r'[^a-zA-Z0-9._-]', '', str(s))
|
||||
|
||||
def safe_int(v, default=0):
|
||||
try:
|
||||
n = int(v)
|
||||
return n if n >= 0 else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def safe_lower_alpha_underscore(s):
|
||||
return re.sub(r'[^a-z_]', '', str(s).lower())
|
||||
|
||||
conv_id = safe_id(data.get('conversationId', ''))
|
||||
transcript = safe(data.get('transcriptPath', ''))
|
||||
wp_arr = data.get('workspacePaths', [])
|
||||
if isinstance(wp_arr, list) and wp_arr:
|
||||
workspace = safe(wp_arr[0])
|
||||
else:
|
||||
workspace = ''
|
||||
artifact = safe(data.get('artifactDirectoryPath', ''))
|
||||
execution_num = safe_int(data.get('executionNum', 0))
|
||||
termination_reason = safe_lower_alpha_underscore(data.get('terminationReason', ''))
|
||||
fully_idle_raw = data.get('fullyIdle', None)
|
||||
if fully_idle_raw is True or str(fully_idle_raw).lower() in ('true', '1', 'yes'):
|
||||
fully_idle = 'True'
|
||||
else:
|
||||
fully_idle = 'False'
|
||||
invocation_num = safe_int(data.get('invocationNum', 0))
|
||||
initial_num_steps = safe_int(data.get('initialNumSteps', 0))
|
||||
|
||||
print('__MEMPAL_PARSE_OK__')
|
||||
print(conv_id)
|
||||
print(transcript)
|
||||
print(workspace)
|
||||
print(artifact)
|
||||
print(execution_num)
|
||||
print(termination_reason)
|
||||
print(fully_idle)
|
||||
print(invocation_num)
|
||||
print(initial_num_steps)
|
||||
" 2>"$MEMPAL_STATE_DIR/antigravity_last_python_err.log"
|
||||
)
|
||||
# Tidy up the err log: keep it iff non-empty (failure happened).
|
||||
if [ -s "$MEMPAL_STATE_DIR/antigravity_last_python_err.log" ]; then
|
||||
chmod 600 "$MEMPAL_STATE_DIR/antigravity_last_python_err.log" 2>/dev/null
|
||||
else
|
||||
rm -f "$MEMPAL_STATE_DIR/antigravity_last_python_err.log" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Transcript path validator ─────────────────────────────────────────
|
||||
#
|
||||
# Mirrors mempalace.hooks_cli._validate_transcript_path: rejects empty,
|
||||
# non-jsonl/json suffixes, and any `..` traversal segment.
|
||||
mempal_is_valid_transcript_path() {
|
||||
local path="$1"
|
||||
[ -n "$path" ] || return 1
|
||||
case "$path" in
|
||||
*.json|*.jsonl) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
case "/$path/" in
|
||||
*/../*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Wing inference ────────────────────────────────────────────────────
|
||||
#
|
||||
# Takes the first workspace path from workspacePaths[] (already
|
||||
# extracted into $1) and derives a `wing_<slug>` name from its leaf
|
||||
# directory. Hyphens become underscores; spaces become underscores.
|
||||
# Empty input yields wing_sessions, matching mempalace.hooks_cli's
|
||||
# fallback.
|
||||
mempal_infer_wing() {
|
||||
local workspace="$1"
|
||||
if [ -z "$workspace" ]; then
|
||||
printf 'wing_sessions'
|
||||
return 0
|
||||
fi
|
||||
# Strip trailing slashes
|
||||
while [ "${workspace}" != "${workspace%/}" ]; do
|
||||
workspace="${workspace%/}"
|
||||
done
|
||||
if [ -z "$workspace" ]; then
|
||||
printf 'wing_sessions'
|
||||
return 0
|
||||
fi
|
||||
local leaf="${workspace##*/}"
|
||||
if [ -z "$leaf" ]; then
|
||||
printf 'wing_sessions'
|
||||
return 0
|
||||
fi
|
||||
# Lowercase + hyphens-to-underscores. tr is bash 3.2 safe; ${var^^}
|
||||
# / ${var//-/_} on a fresh expansion are bash 4+ only.
|
||||
local slug
|
||||
slug=$(printf '%s' "$leaf" | tr 'A-Z' 'a-z' | tr ' -' '__')
|
||||
printf 'wing_%s' "$slug"
|
||||
}
|
||||
|
||||
# ── Save-interval floor ───────────────────────────────────────────────
|
||||
#
|
||||
# Reads MEMPAL_SAVE_INTERVAL from the environment, floors it to >= 1
|
||||
# so that `count % interval` cannot divide by zero. We hit the
|
||||
# divide-by-zero shape on the Cursor PR review; this guards explicitly.
|
||||
mempal_save_interval() {
|
||||
local raw="${MEMPAL_SAVE_INTERVAL:-15}"
|
||||
case "$raw" in
|
||||
''|*[!0-9]*) printf '15'; return 0 ;;
|
||||
esac
|
||||
# Strip leading zeros. bash arithmetic ($((...))) parses any token
|
||||
# starting with `0` as octal, so MEMPAL_SAVE_INTERVAL=08 would
|
||||
# crash $((COUNT % INTERVAL)) with "value too great for base".
|
||||
# Loop while the value still starts with 0 AND has length > 1, so
|
||||
# the literal string "0" is preserved (then floored to 15 below).
|
||||
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
|
||||
raw="${raw#0}"
|
||||
done
|
||||
if [ "$raw" -lt 1 ] 2>/dev/null; then
|
||||
printf '15'
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# ── Atomic counter write ──────────────────────────────────────────────
|
||||
#
|
||||
# Writes $value to $file via a same-directory temp file + `mv -f`.
|
||||
# `mv` (rename) is atomic on a single filesystem, so a concurrent
|
||||
# reader either sees the old contents or the new contents — never a
|
||||
# half-written / truncated file. A plain `printf > file` truncates
|
||||
# first and then writes, leaving a window where a concurrent Stop fire
|
||||
# could read an empty / partial value. Concurrent fires for one
|
||||
# conversation are unlikely (Antigravity serializes turns) but the
|
||||
# previous "written atomically" comment was simply false; this makes
|
||||
# it true.
|
||||
#
|
||||
# bash 3.2 safe. The temp lives in the same directory as the target so
|
||||
# the rename stays on one filesystem (a cross-device mv would fall back
|
||||
# to copy+unlink and lose atomicity). On any failure we degrade to a
|
||||
# direct write rather than leaving the counter unwritten.
|
||||
mempal_write_counter_atomic() {
|
||||
local file="$1"
|
||||
local value="$2"
|
||||
local tmp
|
||||
tmp="$(mktemp "${file}.XXXXXX" 2>/dev/null)" || {
|
||||
printf '%s' "$value" > "$file"
|
||||
return
|
||||
}
|
||||
printf '%s' "$value" > "$tmp"
|
||||
mv -f "$tmp" "$file" 2>/dev/null || {
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
printf '%s' "$value" > "$file"
|
||||
}
|
||||
}
|
||||
|
||||
# ── State-file TTL ────────────────────────────────────────────────────
|
||||
#
|
||||
# Per-conversation state artifacts (antigravity_save_count_<conv>,
|
||||
# antigravity_pending_<conv>, antigravity_woke_<conv>/) accumulate one
|
||||
# set per conversation and are never otherwise removed. Reads
|
||||
# MEMPAL_STATE_TTL_DAYS (default 30), validated digits-only and
|
||||
# leading-zero-stripped like mempal_save_interval so `find -mtime`
|
||||
# never sees a bad token. A value of 0 means "sweep aggressively"
|
||||
# (everything older than today); we floor empty/garbage to 30.
|
||||
mempal_state_ttl_days() {
|
||||
local raw="${MEMPAL_STATE_TTL_DAYS:-30}"
|
||||
case "$raw" in
|
||||
''|*[!0-9]*) printf '30'; return 0 ;;
|
||||
esac
|
||||
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
|
||||
raw="${raw#0}"
|
||||
done
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# ── Stale state GC ────────────────────────────────────────────────────
|
||||
#
|
||||
# Opportunistic sweep of per-conversation state older than the TTL.
|
||||
# Gated to run at most once per 24h via the antigravity_last_sweep
|
||||
# marker so it costs nothing on the vast majority of fires (a single
|
||||
# mtime comparison). When it does run, three `find` passes remove the
|
||||
# stale counter files, pending markers, and woke marker directories.
|
||||
#
|
||||
# The name globs are specific (antigravity_save_count_*, _pending_*,
|
||||
# _woke_*), so the shared log files (antigravity_hook.log,
|
||||
# antigravity_last_input.log, antigravity_last_python_err.log) and the
|
||||
# antigravity_last_sweep marker itself are never touched. BSD find
|
||||
# (macOS default) and GNU find both accept `-maxdepth`, `-mtime +N`,
|
||||
# and `-exec ... +`.
|
||||
#
|
||||
# Fail-open: every step is best-effort; a missing state dir, a find
|
||||
# that errors, or a permission problem must never abort the caller.
|
||||
mempal_gc_stale_state() {
|
||||
[ -d "$MEMPAL_STATE_DIR" ] || return 0
|
||||
|
||||
local marker="$MEMPAL_STATE_DIR/antigravity_last_sweep"
|
||||
if [ -f "$marker" ]; then
|
||||
local mtime now
|
||||
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$marker" 2>/dev/null) \
|
||||
&& now=$(date '+%s' 2>/dev/null) \
|
||||
&& [ -n "$mtime" ] \
|
||||
&& [ "$((now - mtime))" -lt 86400 ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Touch the marker first so a crash mid-sweep still throttles the
|
||||
# next fire (better to skip a sweep than to hammer the disk).
|
||||
: > "$marker" 2>/dev/null
|
||||
|
||||
local ttl
|
||||
ttl=$(mempal_state_ttl_days)
|
||||
|
||||
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
|
||||
-name 'antigravity_save_count_*' -mtime +"$ttl" \
|
||||
-exec rm -f {} + 2>/dev/null
|
||||
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
|
||||
-name 'antigravity_pending_*' -mtime +"$ttl" \
|
||||
-exec rm -f {} + 2>/dev/null
|
||||
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type d \
|
||||
-name 'antigravity_woke_*' -mtime +"$ttl" \
|
||||
-exec rm -rf {} + 2>/dev/null
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Fail-open emitters ────────────────────────────────────────────────
|
||||
#
|
||||
# Every code path in both hooks must terminate by calling exactly one
|
||||
# of these emitters. Stdout is JSON. Exit status is always 0 — the hook
|
||||
# never blocks the user's IDE on its own failure (constraint #2).
|
||||
#
|
||||
# CRITICAL: mempal_emit_stop_pass MUST NEVER emit
|
||||
# {"decision":"continue"} — that would force the agent to keep running
|
||||
# instead of letting the turn end. Antigravity treats any value other
|
||||
# than "continue" (including `{}`) as "allow the stop". We enforce this
|
||||
# by hard-coding the empty object output here.
|
||||
mempal_emit_stop_pass() {
|
||||
printf '{}\n'
|
||||
}
|
||||
|
||||
mempal_emit_wake_inject() {
|
||||
local message="$1"
|
||||
if [ -z "$message" ]; then
|
||||
printf '{}\n'
|
||||
return 0
|
||||
fi
|
||||
# Encode the message as JSON via Python so embedded quotes / newlines
|
||||
# / control chars don't corrupt the output.
|
||||
"$MEMPAL_PYTHON_BIN" -c "
|
||||
import json, sys
|
||||
msg = sys.argv[1]
|
||||
print(json.dumps({'injectSteps': [{'ephemeralMessage': msg}]}))
|
||||
" "$message" 2>/dev/null || printf '{}\n'
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE ANTIGRAVITY SAVE HOOK — Stop event handler
|
||||
#
|
||||
# Antigravity fires the Stop event each time the agent's execution loop
|
||||
# terminates. We use it to background-mine the active conversation
|
||||
# transcript every Nth save into the user's MemPalace, and to write a
|
||||
# diary checkpoint via `mempalace mine --mode convos`.
|
||||
#
|
||||
# Mirrors the Claude Code (hooks/mempal_save_hook.sh) and Codex
|
||||
# (.codex-plugin/hooks/mempal-hook.sh) integrations as closely as the
|
||||
# Antigravity stdin/stdout contract allows. Differences:
|
||||
#
|
||||
# * Antigravity stdin uses camelCase: conversationId, transcriptPath,
|
||||
# workspacePaths, executionNum, terminationReason, fullyIdle.
|
||||
# * Antigravity stdout MUST be `{}` on every code path. Emitting
|
||||
# `{"decision":"continue"}` would force the agent to keep running
|
||||
# and create an infinite loop. We never call mempal_emit_stop_pass
|
||||
# with anything other than the literal empty object.
|
||||
# * Counter file is namespaced antigravity_save_count_<conversationId>
|
||||
# to coexist with Claude Code / Cursor / Codex state in the same
|
||||
# ~/.mempalace/hook_state/ directory.
|
||||
#
|
||||
# === STDIN (verified, camelCase) ===
|
||||
# {
|
||||
# "executionNum": 1,
|
||||
# "terminationReason": "model_stop",
|
||||
# "error": "",
|
||||
# "fullyIdle": true,
|
||||
# "conversationId": "<uuid>",
|
||||
# "workspacePaths": ["/abs/path/..."],
|
||||
# "transcriptPath": "/abs/path/transcript.jsonl",
|
||||
# "artifactDirectoryPath": "/abs/path/artifacts/"
|
||||
# }
|
||||
#
|
||||
# === STDOUT (always) ===
|
||||
# {}
|
||||
#
|
||||
# `set -e` is intentionally NOT enabled — a broken hook must not block
|
||||
# the user's conversation (constraint #2 in the integration brief).
|
||||
|
||||
# ── Locate this script + source common helpers ───────────────────────
|
||||
MEMPAL_AGY_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=lib/common.sh
|
||||
. "$MEMPAL_AGY_HOOK_DIR/lib/common.sh"
|
||||
|
||||
# ── Read all of stdin once ───────────────────────────────────────────
|
||||
INPUT=$(cat)
|
||||
|
||||
# ── Kill switch: short-circuit cleanly if disabled ───────────────────
|
||||
if mempal_kill_switch_tripped; then
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Opportunistic GC of stale per-conversation state ─────────────────
|
||||
#
|
||||
# Self-throttled to at most once per 24h (see mempal_gc_stale_state),
|
||||
# so this is a single mtime check on the overwhelming majority of
|
||||
# fires. Runs after the kill switch so a disabled hook touches nothing.
|
||||
mempal_gc_stale_state
|
||||
|
||||
# ── Parse stdin (camelCase, sentinel-guarded) ────────────────────────
|
||||
_parsed=$(mempal_parse_stdin "$INPUT")
|
||||
_marker=$(printf '%s\n' "$_parsed" | sed -n '1p')
|
||||
CONVERSATION_ID=$(printf '%s\n' "$_parsed" | sed -n '2p')
|
||||
TRANSCRIPT_PATH=$(printf '%s\n' "$_parsed" | sed -n '3p')
|
||||
WORKSPACE_PATH=$(printf '%s\n' "$_parsed" | sed -n '4p')
|
||||
# Line 5 (artifactDirectoryPath) is parsed but unused for save. Skip.
|
||||
EXECUTION_NUM=$(printf '%s\n' "$_parsed" | sed -n '6p')
|
||||
# Line 7 (terminationReason) is parsed but used only for logging.
|
||||
TERMINATION_REASON=$(printf '%s\n' "$_parsed" | sed -n '7p')
|
||||
FULLY_IDLE=$(printf '%s\n' "$_parsed" | sed -n '8p')
|
||||
|
||||
# ── Defense-in-depth: surface raw input on parse failure ─────────────
|
||||
#
|
||||
# When the sentinel is missing, Python crashed before reaching its
|
||||
# print() calls. Persist the offending payload (capped at 4 KB, mode
|
||||
# 0600) so the next debugger doesn't lose a day to log lines that say
|
||||
# "Session unknown".
|
||||
if [ -n "$INPUT" ] && [ "$_marker" != "__MEMPAL_PARSE_OK__" ]; then
|
||||
mempal_log "stop" "unknown" "input parse failed (sentinel missing); see antigravity_last_input.log + antigravity_last_python_err.log"
|
||||
(
|
||||
umask 077
|
||||
printf '%s' "$INPUT" | head -c 4096 > "$MEMPAL_STATE_DIR/antigravity_last_input.log"
|
||||
)
|
||||
chmod 600 "$MEMPAL_STATE_DIR/antigravity_last_input.log" 2>/dev/null
|
||||
# Continue with empty fields; the validators below will reject.
|
||||
fi
|
||||
|
||||
CONVERSATION_ID="${CONVERSATION_ID:-unknown}"
|
||||
TRANSCRIPT_PATH="${TRANSCRIPT_PATH:-}"
|
||||
WORKSPACE_PATH="${WORKSPACE_PATH:-}"
|
||||
EXECUTION_NUM="${EXECUTION_NUM:-0}"
|
||||
TERMINATION_REASON="${TERMINATION_REASON:-}"
|
||||
FULLY_IDLE="${FULLY_IDLE:-False}"
|
||||
|
||||
# Expand ~ in the transcript path
|
||||
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
|
||||
|
||||
# ── Bail when fullyIdle is False ─────────────────────────────────────
|
||||
#
|
||||
# If background commands or async tasks are still running, the
|
||||
# transcript is still in motion. Defer the save until the next Stop
|
||||
# event when the agent is fully done — better to skip than to ingest a
|
||||
# half-finished transcript and pollute the search index.
|
||||
if [ "$FULLY_IDLE" != "True" ]; then
|
||||
mempal_log "stop" "$CONVERSATION_ID" "deferring save: fullyIdle=False (executionNum=$EXECUTION_NUM, terminationReason=$TERMINATION_REASON)"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Skip when terminationReason is `error` ───────────────────────────
|
||||
#
|
||||
# A model error termination usually means the transcript is corrupt or
|
||||
# truncated. Don't ingest noise.
|
||||
if [ "$TERMINATION_REASON" = "error" ]; then
|
||||
mempal_log "stop" "$CONVERSATION_ID" "skipping save: terminationReason=error"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Increment counter (per conversation) ─────────────────────────────
|
||||
#
|
||||
# The counter is a single integer, written via mempal_write_counter_atomic
|
||||
# (same-dir temp + `mv`, which is an atomic rename on one filesystem).
|
||||
# Concurrent Stop fires for the same conversation are unlikely
|
||||
# (Antigravity serializes turns), but the atomic write means a
|
||||
# concurrent reader always sees a complete value rather than a
|
||||
# half-written / truncated file. The integer-only validation on read
|
||||
# is a second guard: any garbled value resets the count to 0.
|
||||
COUNTER_FILE="$MEMPAL_STATE_DIR/antigravity_save_count_${CONVERSATION_ID}"
|
||||
COUNT=0
|
||||
if [ -f "$COUNTER_FILE" ]; then
|
||||
raw=$(cat "$COUNTER_FILE" 2>/dev/null)
|
||||
case "$raw" in
|
||||
''|*[!0-9]*) COUNT=0 ;;
|
||||
*) COUNT="$raw" ;;
|
||||
esac
|
||||
fi
|
||||
COUNT=$((COUNT + 1))
|
||||
mempal_write_counter_atomic "$COUNTER_FILE" "$COUNT"
|
||||
|
||||
INTERVAL=$(mempal_save_interval)
|
||||
mempal_log "stop" "$CONVERSATION_ID" "count=$COUNT interval=$INTERVAL executionNum=$EXECUTION_NUM workspace=$WORKSPACE_PATH"
|
||||
|
||||
# ── Modulo gate ──────────────────────────────────────────────────────
|
||||
#
|
||||
# `count % interval == 0` triggers a save. INTERVAL has been floored to
|
||||
# >= 1 by mempal_save_interval, so the modulo cannot divide by zero
|
||||
# even if the user explicitly set MEMPAL_SAVE_INTERVAL=0 or empty.
|
||||
if [ $((COUNT % INTERVAL)) -ne 0 ]; then
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Pending-marker guard ─────────────────────────────────────────────
|
||||
#
|
||||
# If a previous save is still running (the marker file exists), skip
|
||||
# this fire. The mine subprocess removes the marker on exit, but a
|
||||
# crashed mine could leave the marker forever — guard against that by
|
||||
# treating markers older than 1 hour as stale and reclaiming them.
|
||||
PENDING_FILE="$MEMPAL_STATE_DIR/antigravity_pending_${CONVERSATION_ID}"
|
||||
if [ -f "$PENDING_FILE" ]; then
|
||||
# mtime in epoch seconds (portable; BSD/macOS `date -r` takes epoch, not a path).
|
||||
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$PENDING_FILE" 2>/dev/null) \
|
||||
&& now=$(date '+%s') \
|
||||
&& [ -n "$mtime" ] \
|
||||
&& [ "$((now - mtime))" -lt 3600 ]; then
|
||||
mempal_log "stop" "$CONVERSATION_ID" "pending save still in flight; skipping"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
mempal_log "stop" "$CONVERSATION_ID" "stale pending marker reclaimed"
|
||||
rm -f "$PENDING_FILE" 2>/dev/null
|
||||
fi
|
||||
|
||||
# ── Validate transcript path ─────────────────────────────────────────
|
||||
if ! mempal_is_valid_transcript_path "$TRANSCRIPT_PATH"; then
|
||||
mempal_log "stop" "$CONVERSATION_ID" "invalid transcriptPath rejected: $TRANSCRIPT_PATH"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f "$TRANSCRIPT_PATH" ]; then
|
||||
mempal_log "stop" "$CONVERSATION_ID" "transcriptPath does not exist: $TRANSCRIPT_PATH"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Trigger save ─────────────────────────────────────────────────────
|
||||
WING=$(mempal_infer_wing "$WORKSPACE_PATH")
|
||||
TRANSCRIPT_DIR=$(dirname "$TRANSCRIPT_PATH")
|
||||
|
||||
mempal_log "stop" "$CONVERSATION_ID" "TRIGGERING SAVE wing=$WING transcript_dir=$TRANSCRIPT_DIR"
|
||||
|
||||
# Drop the pending marker BEFORE spawning so a near-simultaneous fire
|
||||
# sees it. If the spawn fails, remove the marker so the next fire can
|
||||
# retry.
|
||||
: > "$PENDING_FILE" 2>/dev/null
|
||||
|
||||
# Detach EVERYTHING heavy into a single background subshell: the
|
||||
# runnability probe, the mine itself, and the pending-marker cleanup.
|
||||
# The foreground returns immediately after spawning, so the hook's
|
||||
# stdout (`{}`) reaches Antigravity within milliseconds.
|
||||
#
|
||||
# Why the probe must NOT run in the foreground: `mempalace --version`
|
||||
# is NOT cheap. Building the `mine` argument parser imports
|
||||
# `mempalace.miner` (-> palace -> backends -> chromadb/onnx) before
|
||||
# argparse ever processes `--version`, so the probe pays the full
|
||||
# cold-start import cost. Running it in the foreground would block the
|
||||
# hook for that entire import and blow the <500ms save budget. Moving
|
||||
# it inside the backgrounded subshell keeps the foreground instant.
|
||||
#
|
||||
# Folding the cleanup into this same subshell also removes the need for
|
||||
# a separate process-liveness polling watcher: the `rm -f
|
||||
# "$PENDING_FILE"` simply runs after the mine returns, in the same
|
||||
# shell that owns the mine — no sibling-PID `wait` hazard, no polling
|
||||
# loop.
|
||||
#
|
||||
# We invoke mempalace as `"$MEMPAL_PYTHON_BIN" -m mempalace` rather than
|
||||
# the bare `mempalace` console script so a user with the package
|
||||
# installed only inside a venv (and the venv's bin/ not on the hook's
|
||||
# PATH, e.g. `uv tool install` in some distributions, or a manually
|
||||
# managed virtualenv) still hits a working mine. MEMPAL_PYTHON honours
|
||||
# user override; sees ``mempalace/__main__.py`` which dispatches to
|
||||
# ``mempalace.cli:main`` — identical to the console script.
|
||||
mempal_log "stop" "$CONVERSATION_ID" "spawning background mine wing=$WING transcript_dir=$TRANSCRIPT_DIR"
|
||||
(
|
||||
if "$MEMPAL_PYTHON_BIN" -m mempalace --version >/dev/null 2>&1; then
|
||||
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$TRANSCRIPT_DIR" \
|
||||
--mode convos \
|
||||
--wing "$WING" \
|
||||
>> "$MEMPAL_AGY_LOG" 2>&1 < /dev/null
|
||||
mempal_log "stop" "$CONVERSATION_ID" "background mine finished wing=$WING"
|
||||
else
|
||||
mempal_log "stop" "$CONVERSATION_ID" "ERROR: mempalace is not runnable via $MEMPAL_PYTHON_BIN -m mempalace; install mempalace or set MEMPAL_PYTHON"
|
||||
fi
|
||||
rm -f "$PENDING_FILE" 2>/dev/null
|
||||
) >/dev/null 2>&1 < /dev/null &
|
||||
|
||||
# ── Always emit `{}` ─────────────────────────────────────────────────
|
||||
#
|
||||
# Never `{"decision":"continue"}`. That would force the agent into an
|
||||
# infinite re-execution loop. mempal_emit_stop_pass hard-codes `{}`.
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE ANTIGRAVITY WAKE HOOK — PreInvocation event handler
|
||||
#
|
||||
# Antigravity fires the PreInvocation event before every model
|
||||
# invocation, with `invocationNum` carrying the sequence number of the
|
||||
# call. We use the first invocation (invocationNum == 1) as our
|
||||
# session-start equivalent and inject a verbatim memory pointer into
|
||||
# the agent's context via the `injectSteps[].ephemeralMessage` output
|
||||
# field — the message lives for one turn and does not persist into the
|
||||
# transcript, so it doesn't pollute future invocations of this same
|
||||
# conversation.
|
||||
#
|
||||
# === STDIN (verified, camelCase) ===
|
||||
# {
|
||||
# "invocationNum": 1,
|
||||
# "initialNumSteps": 0,
|
||||
# "conversationId": "<uuid>",
|
||||
# "workspacePaths": ["/abs/path/..."],
|
||||
# "transcriptPath": "/abs/path/transcript.jsonl",
|
||||
# "artifactDirectoryPath": "/abs/path/artifacts/"
|
||||
# }
|
||||
#
|
||||
# === STDOUT ===
|
||||
# Either:
|
||||
# {} — no injection
|
||||
# Or:
|
||||
# {"injectSteps":[{"ephemeralMessage":"..."}]} — verbatim memory pointer
|
||||
#
|
||||
# Verbatim guarantee: the ephemeralMessage carries the exact text
|
||||
# emitted by `mempalace wake-up`, never paraphrased or summarized.
|
||||
#
|
||||
# Performance budget: the integration brief sets a 100ms ceiling for
|
||||
# startup injection. We enforce a 500ms hard timeout on the
|
||||
# `mempalace wake-up` subprocess (more generous than 100ms because
|
||||
# cold ChromaDB connections can dominate, and missing the budget is
|
||||
# strictly better than blocking the user) — if it doesn't return in
|
||||
# time we emit `{}` and let the conversation start without injection.
|
||||
#
|
||||
# `set -e` is intentionally NOT enabled — fail-open is mandatory.
|
||||
|
||||
# ── Locate this script + source common helpers ───────────────────────
|
||||
MEMPAL_AGY_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=lib/common.sh
|
||||
. "$MEMPAL_AGY_HOOK_DIR/lib/common.sh"
|
||||
|
||||
# ── Read all of stdin once ───────────────────────────────────────────
|
||||
INPUT=$(cat)
|
||||
|
||||
# ── Kill switch ──────────────────────────────────────────────────────
|
||||
if mempal_kill_switch_tripped; then
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Parse stdin ──────────────────────────────────────────────────────
|
||||
_parsed=$(mempal_parse_stdin "$INPUT")
|
||||
_marker=$(printf '%s\n' "$_parsed" | sed -n '1p')
|
||||
CONVERSATION_ID=$(printf '%s\n' "$_parsed" | sed -n '2p')
|
||||
# Lines 3-5 (transcriptPath, workspacePath, artifactDirectoryPath) are
|
||||
# parsed; we use workspacePath for wing inference. transcriptPath and
|
||||
# artifactDirectoryPath are unused by the wake flow.
|
||||
# Line 4: workspacePath
|
||||
WORKSPACE_PATH=$(printf '%s\n' "$_parsed" | sed -n '4p')
|
||||
INVOCATION_NUM=$(printf '%s\n' "$_parsed" | sed -n '9p')
|
||||
|
||||
# Defense-in-depth on parse failure
|
||||
if [ -n "$INPUT" ] && [ "$_marker" != "__MEMPAL_PARSE_OK__" ]; then
|
||||
mempal_log "preInvocation" "unknown" "input parse failed (sentinel missing)"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CONVERSATION_ID="${CONVERSATION_ID:-unknown}"
|
||||
WORKSPACE_PATH="${WORKSPACE_PATH:-}"
|
||||
INVOCATION_NUM="${INVOCATION_NUM:-0}"
|
||||
|
||||
# ── Gate: only inject on the FIRST invocation ────────────────────────
|
||||
#
|
||||
# PreInvocation fires before every model call. Without this gate we'd
|
||||
# inject memory on every single turn — both expensive and visually
|
||||
# noisy. invocationNum == 1 means "first model call of this
|
||||
# conversation", which is the closest thing Antigravity exposes to
|
||||
# Cursor's `sessionStart`.
|
||||
if [ "$INVOCATION_NUM" != "1" ]; then
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Loop guard ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Defense in depth: even within the first invocation, we only ever
|
||||
# want to inject once per conversation. mkdir is atomic and works on
|
||||
# bash 3.2 / macOS / Linux without flock or other GNU coreutils
|
||||
# extensions.
|
||||
WOKE_MARKER="$MEMPAL_STATE_DIR/antigravity_woke_${CONVERSATION_ID}"
|
||||
if ! mkdir "$WOKE_MARKER" 2>/dev/null; then
|
||||
mempal_log "preInvocation" "$CONVERSATION_ID" "already woke this conversation; skipping"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Run wake-up with a hard timeout ──────────────────────────────────
|
||||
#
|
||||
# `timeout` is GNU coreutils — present on most Linux installs but
|
||||
# missing from stock macOS. Wrap the subprocess in a Python timeout
|
||||
# (subprocess.run(timeout=...)) which is cross-platform. The Python
|
||||
# script also constructs the final JSON envelope for stdout, so the
|
||||
# bash side just passes the result through.
|
||||
WING=$(mempal_infer_wing "$WORKSPACE_PATH")
|
||||
mempal_log "preInvocation" "$CONVERSATION_ID" "WAKE injection wing=$WING invocationNum=$INVOCATION_NUM"
|
||||
|
||||
OUTPUT=$("$MEMPAL_PYTHON_BIN" -c "
|
||||
import json, subprocess, sys
|
||||
|
||||
wing = sys.argv[1]
|
||||
timeout_s = 0.5 # 500 ms
|
||||
|
||||
# Invoke as ``[sys.executable, '-m', 'mempalace', ...]`` rather than
|
||||
# the bare ``mempalace`` console script. sys.executable is the same
|
||||
# Python that resolved MEMPAL_PYTHON in lib/common.sh, so this binds
|
||||
# the wake-up call to the correct interpreter (and its installed
|
||||
# mempalace package) even when the venv's bin/ isn't on PATH.
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, '-m', 'mempalace', 'wake-up', '--wing', wing],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
print('{}')
|
||||
sys.exit(0)
|
||||
body = (completed.stdout or '').strip()
|
||||
if not body:
|
||||
print('{}')
|
||||
sys.exit(0)
|
||||
# Verbatim — pass the wake-up text exactly as emitted, wrapped in
|
||||
# the Antigravity injectSteps envelope. json.dumps escapes embedded
|
||||
# control chars and quotes correctly.
|
||||
print(json.dumps({'injectSteps': [{'ephemeralMessage': body}]}))
|
||||
except FileNotFoundError:
|
||||
print('{}')
|
||||
except subprocess.TimeoutExpired:
|
||||
print('{}')
|
||||
except Exception:
|
||||
print('{}')
|
||||
" "$WING" 2>/dev/null)
|
||||
|
||||
if [ -z "$OUTPUT" ]; then
|
||||
OUTPUT='{}'
|
||||
fi
|
||||
|
||||
# Sanity-check: never emit `decision` from a PreInvocation hook (that
|
||||
# field belongs to the Stop event). The Python helper only ever
|
||||
# constructs `{"injectSteps": [...]}` or `{}`, so this is belt-and-
|
||||
# suspenders against a future edit ever leaking a Stop-shaped object.
|
||||
case "$OUTPUT" in
|
||||
*\"decision\"*)
|
||||
mempal_log "preInvocation" "$CONVERSATION_ID" "ERROR: refused to emit decision field from wake hook"
|
||||
mempal_emit_stop_pass
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '%s\n' "$OUTPUT"
|
||||
exit 0
|
||||
@@ -0,0 +1,157 @@
|
||||
# MemPalace Cursor IDE Hooks
|
||||
|
||||
Auto-save and session-recall hooks for the [Cursor](https://cursor.com) IDE,
|
||||
matching the behaviour of the existing Claude Code + Codex hooks at the repo
|
||||
root and adding two Cursor-only capabilities (`sessionStart` recall and a
|
||||
preCompact transcript snapshot).
|
||||
|
||||
For the rendered documentation see
|
||||
[`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) or
|
||||
the published version at
|
||||
[mempalaceofficial.com/guide/cursor-hooks](https://mempalaceofficial.com/guide/cursor-hooks.html).
|
||||
|
||||
## What's here
|
||||
|
||||
| File | Role |
|
||||
|-------------------------------------|---------------------------------------------------------------------|
|
||||
| `lib/common.sh` | Shared bash helpers (parse, log, counter, wing inference, kill switch). Sourced by all three hooks. |
|
||||
| `mempal_save_hook_cursor.sh` | Cursor `stop` hook. Counts stop invocations per conversation, emits a `followup_message` every `SAVE_INTERVAL` (default 15) telling the agent to file the session into MemPalace. |
|
||||
| `mempal_precompact_hook_cursor.sh` | Cursor `preCompact` hook. Runs `mempalace mine` synchronously on the transcript before compaction, then drops a `.pending` marker so the next stop forces a save nudge. |
|
||||
| `mempal_wake_hook_cursor.sh` | Cursor `sessionStart` hook. Returns `additional_context` telling the agent to recall scoped to the wing inferred from the workspace root. Cursor-only — Claude Code has no equivalent. |
|
||||
| `install.sh` | Optional installer. Copies the scripts to `~/.mempalace/hooks/cursor/` and merges entries into `~/.cursor/hooks.json` (or `.cursor/hooks.json` for project scope). Supports `--dry-run` and `--uninstall`. |
|
||||
| `STDIN_SHAPE.md` | Reference. Per-event stdin / stdout schema with citations to the official Cursor docs. |
|
||||
|
||||
## Quick install
|
||||
|
||||
Preview first (writes nothing, prints the would-be JSON to stdout):
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope user --dry-run
|
||||
```
|
||||
|
||||
Apply — writes `~/.cursor/hooks.json` and copies the scripts to `~/.mempalace/hooks/cursor/`:
|
||||
|
||||
```bash
|
||||
hooks/cursor/install.sh --scope user
|
||||
```
|
||||
|
||||
Pass `--scope project --target <repo>` to write `<repo>/.cursor/hooks.json` instead.
|
||||
The installer never auto-runs — it is a documented opt-in step. We do not
|
||||
modify your Cursor config on `pip install mempalace` because editor config
|
||||
is sacred and should never be touched without explicit consent.
|
||||
|
||||
## Manual install (no installer)
|
||||
|
||||
The minimum wiring is `stop` only. Add to `~/.cursor/hooks.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"stop": [
|
||||
{
|
||||
"command": "/absolute/path/to/hooks/cursor/mempal_save_hook_cursor.sh",
|
||||
"loop_limit": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For the full triple (recommended), also wire `sessionStart` and `preCompact`
|
||||
— see [`examples/cursor/hooks.json`](../../examples/cursor/hooks.json).
|
||||
|
||||
After editing the file, Cursor watches `hooks.json` and reloads
|
||||
automatically. If hooks still do not fire, restart Cursor and check the
|
||||
Hooks panel in Settings.
|
||||
|
||||
## Configuration
|
||||
|
||||
All knobs are env vars; defaults match the Claude Code hooks where
|
||||
possible so a single hook-state directory works for both editors.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|--------------------------------|------------------------------------|---------|
|
||||
| `MEMPAL_SAVE_INTERVAL` | `15` | Number of `stop` events between save followups. |
|
||||
| `MEMPAL_CURSOR_SILENT` | (unset) | Set to `1`/`true`/`yes` to suppress the `followup_message`. The hook still runs its best-effort background mine and keeps its counters — it just stays silent. `MEMPAL_VERBOSE=false`/`0`/`no` does the same. See note below on why the followup is on by default. |
|
||||
| `MEMPAL_DIR` | (unset) | Optional project directory to also mine on each save. Additive — never replaces the transcript mine. |
|
||||
| `MEMPAL_PYTHON` | auto-detected | Path to a Python 3 interpreter. Fallback order: `$MEMPAL_PYTHON` → `command -v python3` → bare `python3`. Useful when Cursor is launched from a GUI on macOS and the inherited PATH lacks your installed `python3`. |
|
||||
| `MEMPAL_STATE_DIR` | `$HOME/.mempalace/hook_state` | Where the hook keeps its per-conversation counter files, pending-save markers, and `cursor_hook.log`. |
|
||||
| `MEMPAL_STATE_TTL_DAYS` | `30` | Age (days) after which stale `cursor_*.count` / `cursor_*.pending` state files are garbage-collected. A daily-throttled sweep runs from the hooks; only Cursor state is touched (shared logs and other editors' state are left alone). |
|
||||
| `MEMPAL_DISABLE_HOOK` | (unset) | Set to `1`/`true`/`yes` to disable all three hooks. Emergency kill switch. |
|
||||
| `MEMPALACE_HOOKS_AUTO_SAVE` | (unset) | Set to `false`/`0`/`no` to disable. Same semantics as the Claude Code hooks. Also honoured via `~/.mempalace/config.json` → `{"hooks": {"auto_save": false}}`. |
|
||||
|
||||
## Debugging
|
||||
|
||||
Everything appends to:
|
||||
|
||||
```bash
|
||||
cat ~/.mempalace/hook_state/cursor_hook.log
|
||||
```
|
||||
|
||||
Example log lines (ISO 8601 + event + conversation id):
|
||||
|
||||
```
|
||||
[2026-05-27T02:16:01Z] [event=sessionStart] [conv=abc123] workspace=/Users/me/proj wing=proj
|
||||
[2026-05-27T02:21:33Z] [event=stop] [conv=abc123] counter 0 -> 1 (interval=15)
|
||||
[2026-05-27T02:42:09Z] [event=stop] [conv=abc123] counter 14 -> 15 (interval=15)
|
||||
[2026-05-27T02:42:09Z] [event=stop] [conv=abc123] TRIGGERING SAVE at counter=15
|
||||
[2026-05-27T02:42:11Z] [event=stop] [conv=abc123] loop_count>0; letting agent stop
|
||||
```
|
||||
|
||||
When a hook can't parse its stdin (corrupt payload, future Cursor schema
|
||||
change), the raw input (capped at 4096 bytes, mode 0600) lands at:
|
||||
|
||||
```
|
||||
~/.mempalace/hook_state/cursor_last_input.log
|
||||
~/.mempalace/hook_state/cursor_last_python_err.log
|
||||
```
|
||||
|
||||
These are overwritten on each failure, never appended, so a repeating
|
||||
misconfiguration cannot grow disk usage.
|
||||
|
||||
## What differs from the Claude Code hooks
|
||||
|
||||
| Aspect | Claude Code hooks (`hooks/mempal_*.sh`) | Cursor hooks (`hooks/cursor/*.sh`) |
|
||||
|-------------------------|------------------------------------------------|-----------------------------------------------------|
|
||||
| Counter key | `session_id` | `conversation_id` (Cursor's stable per-conv id) |
|
||||
| Loop guard | `stop_hook_active` flag in stdin | `loop_count` field in stdin |
|
||||
| Counting method | Parses JSONL transcript for user messages | Counts `stop` invocations (transcript schema undoc) |
|
||||
| Capture path | Background `mine --mode convos` (normalize.py has a Claude parser) | Background mine is best-effort (no Cursor parser); the `followup_message` carries verbatim capture |
|
||||
| Save default | Silent — diary nudge opt-IN behind `MEMPAL_VERBOSE=true` | Followup ON by default; opt-OUT via `MEMPAL_CURSOR_SILENT=1` / `MEMPAL_VERBOSE=false` |
|
||||
| PreCompact behaviour | `decision: block` forces save before compaction | Pre-mine + pending-save marker (Cursor preCompact is observational-only) |
|
||||
| sessionStart | n/a (Claude Code has no equivalent) | `additional_context` injects recall guidance |
|
||||
| State dir | `$HOME/.mempalace/hook_state` (hardcoded) | Same default, plus `MEMPAL_STATE_DIR` env override |
|
||||
| Kill switch | `MEMPALACE_HOOKS_AUTO_SAVE=false` | Same, plus `MEMPAL_DISABLE_HOOK=1` alias |
|
||||
| Log file | `hook.log` | `cursor_hook.log` (kept separate to avoid cross-tool log churn) |
|
||||
|
||||
See [`STDIN_SHAPE.md`](STDIN_SHAPE.md) for the per-event schema and
|
||||
[`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) for
|
||||
the full walkthrough with diagrams.
|
||||
|
||||
## Why the followup is on by default (Cursor-specific)
|
||||
|
||||
Unlike the Claude Code hook — which is silent by default because its
|
||||
background `mempalace mine --mode convos` captures the verbatim transcript
|
||||
on its own — Cursor's transcript format is **undocumented** and
|
||||
`mempalace/normalize.py` has **no Cursor parser**. The background mine on
|
||||
the Cursor `stop`/`preCompact` hooks is therefore **best-effort only**: it
|
||||
does not yet yield clean verbatim conversation drawers.
|
||||
|
||||
That makes the `followup_message` the **load-bearing verbatim-capture
|
||||
path** for Cursor — it drives the agent to file its own in-context
|
||||
verbatim quotes via `mempalace_add_drawer` / `mempalace_diary_write`.
|
||||
Silencing it by default would leave a default Cursor install capturing
|
||||
nothing, which is why it is on by default here. Set `MEMPAL_CURSOR_SILENT=1`
|
||||
(or `MEMPAL_VERBOSE=false`) if you prefer the Claude-style silent
|
||||
behaviour and accept the reduced capture. Once `normalize.py` learns to
|
||||
read Cursor transcripts, this default will flip to silent to match Claude.
|
||||
|
||||
## Cost
|
||||
|
||||
Zero extra LLM tokens spent by the hooks themselves. The hooks are local
|
||||
bash scripts that run on your machine. The followup message the save hook
|
||||
emits is a normal user turn — it counts the same as any other user message
|
||||
and does not invoke any extra LLM call beyond the one the user would
|
||||
otherwise make. Suppress it with `MEMPAL_CURSOR_SILENT=1` if you want zero
|
||||
followups in the chat window.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Cursor Hook Stdin Shape — Reference
|
||||
|
||||
This file documents the JSON payloads the Cursor IDE sends to the
|
||||
MemPalace hook scripts in `hooks/cursor/`. It exists so a future
|
||||
contributor does not have to re-discover the schema by writing a
|
||||
probe hook.
|
||||
|
||||
**Source:** [`cursor.com/docs/hooks.md`](https://cursor.com/docs/hooks.md),
|
||||
fetched 2026-05-27. Cursor's hook system is documented as a stable
|
||||
v1 schema (`{"version": 1, ...}` at the top of `hooks.json`).
|
||||
|
||||
If you suspect Cursor has changed the payload shape since that fetch
|
||||
date, re-verify against the upstream docs and update both this file
|
||||
and `hooks/cursor/lib/common.sh::mempal_parse_stdin`. The hook
|
||||
scripts deliberately ignore fields they do not consume, so adding
|
||||
new fields is non-breaking.
|
||||
|
||||
## Common fields (all events)
|
||||
|
||||
Every hook receives these on stdin in addition to its event-specific
|
||||
fields. Source: docs section "Common schema → Input (all hooks)".
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation_id": "string",
|
||||
"generation_id": "string",
|
||||
"model": "string",
|
||||
"hook_event_name": "string",
|
||||
"cursor_version": "string",
|
||||
"workspace_roots": ["<absolute path>"],
|
||||
"user_email": "string | null",
|
||||
"transcript_path": "string | null"
|
||||
}
|
||||
```
|
||||
|
||||
**Field notes (verified):**
|
||||
|
||||
- `conversation_id` is the stable per-conversation ID. The Cursor
|
||||
`stop` event does **not** carry a `session_id` — only
|
||||
`conversation_id`. MemPalace keys its counter files on this. Cursor
|
||||
`sessionStart` does carry a `session_id`, and the docs note it is
|
||||
"same as `conversation_id`".
|
||||
- `generation_id` changes every user message. We do not use it.
|
||||
- `transcript_path` may be `null` if the user has disabled
|
||||
transcripts in Cursor settings. The hooks degrade gracefully when
|
||||
the value is empty.
|
||||
- `workspace_roots` is normally a single-entry array but multi-root
|
||||
workspaces are supported; MemPalace uses index `[0]`.
|
||||
|
||||
## Event-specific fields
|
||||
|
||||
### `stop` (consumed by `mempal_save_hook_cursor.sh`)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "completed" | "aborted" | "error",
|
||||
"loop_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
- `loop_count` indicates how many times this stop hook has already
|
||||
triggered an automatic followup for this conversation (starts at
|
||||
0). When `loop_count > 0` we know our own previous `followup_message`
|
||||
is currently being processed — the save hook returns `{}` so the
|
||||
agent can finish. Equivalent to Claude Code's `stop_hook_active`.
|
||||
- The per-script `loop_limit` (default 5 for Cursor hooks, configurable
|
||||
via the `loop_limit` field on the hook entry in `hooks.json`) is
|
||||
defense-in-depth on top of our own check. The example `hooks.json`
|
||||
in `examples/cursor/` sets `loop_limit: 1`.
|
||||
|
||||
**Allowed output fields** (only):
|
||||
|
||||
```json
|
||||
{ "followup_message": "<text to auto-submit as next user turn>" }
|
||||
```
|
||||
|
||||
### `preCompact` (consumed by `mempal_precompact_hook_cursor.sh`)
|
||||
|
||||
```json
|
||||
{
|
||||
"trigger": "auto" | "manual",
|
||||
"context_usage_percent": 85,
|
||||
"context_tokens": 120000,
|
||||
"context_window_size": 128000,
|
||||
"message_count": 45,
|
||||
"messages_to_compact": 30,
|
||||
"is_first_compaction": true
|
||||
}
|
||||
```
|
||||
|
||||
**Critical constraint:** preCompact is documented as **observational
|
||||
only**. It cannot block compaction and its allowed output fields are
|
||||
limited to:
|
||||
|
||||
```json
|
||||
{ "user_message": "<short message shown to the user when compaction occurs>" }
|
||||
```
|
||||
|
||||
There is **no** `followup_message` and **no** `decision: block` on
|
||||
this event — unlike Claude Code's `PreCompact`. MemPalace works
|
||||
around this by:
|
||||
|
||||
1. Running `mempalace mine` synchronously inside the hook so the
|
||||
verbatim transcript lands in the palace before compaction
|
||||
summarises it.
|
||||
2. Dropping a `cursor_<conversation_id>.pending` marker that the next
|
||||
`stop` invocation reads and uses to force a save followup
|
||||
regardless of its counter.
|
||||
|
||||
### `sessionStart` (consumed by `mempal_wake_hook_cursor.sh`)
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "<unique session identifier>",
|
||||
"is_background_agent": true,
|
||||
"composer_mode": "agent" | "ask" | "edit"
|
||||
}
|
||||
```
|
||||
|
||||
`session_id` equals `conversation_id` on this event (docs are
|
||||
explicit about this).
|
||||
|
||||
**Allowed output fields:**
|
||||
|
||||
```json
|
||||
{
|
||||
"env": { "<key>": "<value>" },
|
||||
"additional_context": "<text added to conversation's initial system context>"
|
||||
}
|
||||
```
|
||||
|
||||
`additional_context` is the field MemPalace uses. The schema also
|
||||
accepts `continue` and `user_message` but the docs explicitly note
|
||||
"current callers do not enforce them; session creation is not
|
||||
blocked even when continue is false". We do not emit either.
|
||||
|
||||
## Environment variables (all hooks)
|
||||
|
||||
Cursor sets these env vars on every hook execution; the hook scripts
|
||||
fall back to them when JSON parsing fails for any reason.
|
||||
|
||||
| Variable | Description |
|
||||
|---------------------------|---------------------------------------------------|
|
||||
| `CURSOR_PROJECT_DIR` | Workspace root (= `workspace_roots[0]`) |
|
||||
| `CURSOR_VERSION` | Cursor version string |
|
||||
| `CURSOR_USER_EMAIL` | Authenticated user email (if logged in) |
|
||||
| `CURSOR_TRANSCRIPT_PATH` | Conversation transcript path (if transcripts on) |
|
||||
| `CURSOR_CODE_REMOTE` | `"true"` if running in a remote workspace |
|
||||
| `CLAUDE_PROJECT_DIR` | Alias for `CURSOR_PROJECT_DIR` (Claude compat) |
|
||||
|
||||
## Exit code semantics
|
||||
|
||||
Cursor interprets command-hook exit codes as follows
|
||||
(docs "Hook Types → Command-Based Hooks → Exit code behavior"):
|
||||
|
||||
- `0` — success, use the JSON output.
|
||||
- `2` — block the action (equivalent to `permission: "deny"`).
|
||||
- Other — hook failed; action proceeds (fail-open by default).
|
||||
|
||||
MemPalace hooks always exit `0` and emit either `{}` (no-op) or a
|
||||
valid JSON response. We never use exit code `2`; nothing MemPalace
|
||||
does should ever block an agent action.
|
||||
|
||||
## Working directory contract
|
||||
|
||||
- **User hooks** (`~/.cursor/hooks.json`) run from `~/.cursor/`.
|
||||
- **Project hooks** (`.cursor/hooks.json`) run from the project root.
|
||||
|
||||
The MemPalace hooks always resolve their sibling `lib/common.sh` via
|
||||
`BASH_SOURCE[0]` so the working directory does not matter for the
|
||||
script's own loading — only the `command` path in `hooks.json` needs
|
||||
to point at the absolute location of the script.
|
||||
|
||||
## Transcript file format (out of scope)
|
||||
|
||||
The format of the file at `transcript_path` is **not documented by
|
||||
Cursor** as of the fetch date above. MemPalace deliberately does not
|
||||
parse it: the save hook counts `stop` invocations (each one
|
||||
corresponds to one assistant turn) and hands the transcript to
|
||||
`mempalace mine`, which has its own normaliser layer.
|
||||
|
||||
If you need to consume the transcript directly, probe its shape with
|
||||
a throw-away hook that does `cat > /tmp/cursor-transcript-sample.txt`
|
||||
and inspect the output — there is no shortcut.
|
||||
Executable
+399
@@ -0,0 +1,399 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE CURSOR HOOK INSTALLER
|
||||
#
|
||||
# Optional helper. Copies the three Cursor hook scripts to a
|
||||
# stable install location and merges entries into a Cursor
|
||||
# `hooks.json` config file — without clobbering unrelated hooks
|
||||
# already in that file.
|
||||
#
|
||||
# This is NEVER auto-invoked. Editor config is sacred; we do not
|
||||
# modify a user's hooks.json without explicit consent. The user runs
|
||||
# this script (or wires the hooks manually) as a documented opt-in.
|
||||
#
|
||||
# === USAGE ===
|
||||
#
|
||||
# hooks/cursor/install.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --scope user|project Target scope. Default: user.
|
||||
# - user: merges into ~/.cursor/hooks.json
|
||||
# - project: merges into <target>/.cursor/hooks.json
|
||||
# --target <path> Project root for --scope project (default: $PWD).
|
||||
# Ignored for --scope user.
|
||||
# --install-dir <path> Where to copy the hook scripts.
|
||||
# Default: ~/.mempalace/hooks/cursor
|
||||
# --variant full|minimal Which hook set to wire.
|
||||
# - full: stop + preCompact + sessionStart
|
||||
# - minimal: stop only
|
||||
# Default: full.
|
||||
# --dry-run Print the would-be JSON to stdout, do not write
|
||||
# and do not copy scripts.
|
||||
# --uninstall Remove MemPalace entries from the target
|
||||
# hooks.json (preserves unrelated hooks).
|
||||
# Does NOT delete the installed scripts.
|
||||
# -h, --help Show this help and exit.
|
||||
#
|
||||
# === PORTABILITY ===
|
||||
#
|
||||
# Pure bash 3.2 + POSIX tools + python3 (which the hook scripts
|
||||
# themselves already require). No `jq` dependency.
|
||||
#
|
||||
# Python helpers are materialised to temp files rather than piped via
|
||||
# `$(... <<'PYEOF' ... PYEOF)` to dodge the bash 3.2.57 parser bug
|
||||
# that trips on parens nested inside a heredoc body that lives inside
|
||||
# a `$(...)` command substitution.
|
||||
|
||||
set -e
|
||||
|
||||
usage() {
|
||||
sed -n '2,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────
|
||||
SCOPE="user"
|
||||
TARGET=""
|
||||
INSTALL_DIR="$HOME/.mempalace/hooks/cursor"
|
||||
VARIANT="full"
|
||||
DRY_RUN=0
|
||||
UNINSTALL=0
|
||||
|
||||
# ── Parse args ────────────────────────────────────────────────────
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--scope)
|
||||
shift
|
||||
SCOPE="${1:-}"
|
||||
;;
|
||||
--target)
|
||||
shift
|
||||
TARGET="${1:-}"
|
||||
;;
|
||||
--install-dir)
|
||||
shift
|
||||
INSTALL_DIR="${1:-}"
|
||||
;;
|
||||
--variant)
|
||||
shift
|
||||
VARIANT="${1:-}"
|
||||
;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--uninstall) UNINSTALL=1 ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'install.sh: unknown argument: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
shift || true
|
||||
done
|
||||
|
||||
case "$SCOPE" in
|
||||
user|project) ;;
|
||||
*)
|
||||
printf 'install.sh: --scope must be "user" or "project" (got "%s")\n' \
|
||||
"$SCOPE" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$VARIANT" in
|
||||
full|minimal) ;;
|
||||
*)
|
||||
printf 'install.sh: --variant must be "full" or "minimal" (got "%s")\n' \
|
||||
"$VARIANT" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Resolve paths ─────────────────────────────────────────────────
|
||||
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
|
||||
SOURCE_DIR="$_script_dir"
|
||||
|
||||
# Resolve --install-dir to an absolute path before it gets baked into
|
||||
# hooks.json. Cursor invokes hook commands from its own working
|
||||
# directory (typically the project root), so a relative command path
|
||||
# would silently fail to launch the hook. gh-PR review caught this.
|
||||
case "$INSTALL_DIR" in
|
||||
/*) ;;
|
||||
*) INSTALL_DIR="$PWD/$INSTALL_DIR" ;;
|
||||
esac
|
||||
|
||||
# Resolve the Python interpreter the same way the hooks themselves do
|
||||
# so a user with a non-default Python is consistent across install +
|
||||
# runtime.
|
||||
if [ -n "${MEMPAL_PYTHON:-}" ] && [ -x "$MEMPAL_PYTHON" ]; then
|
||||
PYTHON_BIN="$MEMPAL_PYTHON"
|
||||
else
|
||||
PYTHON_BIN="$(command -v python3 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$PYTHON_BIN" ]; then
|
||||
printf 'install.sh: python3 not found on PATH; cannot proceed.\n' >&2
|
||||
printf 'Set $MEMPAL_PYTHON to an interpreter path or install python3.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine the target hooks.json path.
|
||||
case "$SCOPE" in
|
||||
user)
|
||||
if [ -n "$TARGET" ]; then
|
||||
printf 'install.sh: --target is only meaningful with --scope project; ignoring.\n' >&2
|
||||
fi
|
||||
TARGET_DIR="$HOME/.cursor"
|
||||
;;
|
||||
project)
|
||||
TARGET_DIR="${TARGET:-$PWD}/.cursor"
|
||||
;;
|
||||
esac
|
||||
TARGET_FILE="$TARGET_DIR/hooks.json"
|
||||
|
||||
# Determine which commands the merge / uninstall logic should
|
||||
# install or remove. Paths point at the install location, NOT the
|
||||
# source repo — once the user runs install.sh they can move / delete
|
||||
# the cloned repo without breaking the wiring.
|
||||
SAVE_CMD="$INSTALL_DIR/mempal_save_hook_cursor.sh"
|
||||
PRECOMPACT_CMD="$INSTALL_DIR/mempal_precompact_hook_cursor.sh"
|
||||
WAKE_CMD="$INSTALL_DIR/mempal_wake_hook_cursor.sh"
|
||||
|
||||
# ── Step 1: copy scripts (skipped on --dry-run / --uninstall) ─────
|
||||
if [ "$UNINSTALL" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
|
||||
mkdir -p "$INSTALL_DIR/lib"
|
||||
cp "$SOURCE_DIR/lib/common.sh" "$INSTALL_DIR/lib/common.sh"
|
||||
cp "$SOURCE_DIR/mempal_save_hook_cursor.sh" "$INSTALL_DIR/"
|
||||
cp "$SOURCE_DIR/mempal_precompact_hook_cursor.sh" "$INSTALL_DIR/"
|
||||
cp "$SOURCE_DIR/mempal_wake_hook_cursor.sh" "$INSTALL_DIR/"
|
||||
chmod +x "$INSTALL_DIR/mempal_save_hook_cursor.sh" \
|
||||
"$INSTALL_DIR/mempal_precompact_hook_cursor.sh" \
|
||||
"$INSTALL_DIR/mempal_wake_hook_cursor.sh"
|
||||
printf 'install.sh: copied scripts to %s\n' "$INSTALL_DIR" >&2
|
||||
fi
|
||||
|
||||
# ── Short-circuit: uninstall with no existing file is a no-op ─────
|
||||
#
|
||||
# Without this, the merge step would happily write an empty
|
||||
# {"version": 1, "hooks": {}} to a brand new file that the user
|
||||
# never asked us to create — surprising behaviour that the test
|
||||
# suite explicitly guards against.
|
||||
if [ "$UNINSTALL" -eq 1 ] && [ ! -f "$TARGET_FILE" ]; then
|
||||
printf 'install.sh: nothing to uninstall (%s does not exist)\n' \
|
||||
"$TARGET_FILE" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Step 2: merge / unmerge hooks.json via python3 ────────────────
|
||||
#
|
||||
# Materialise the merge logic to a temp .py file (see bash 3.2
|
||||
# rationale at the top of this script), then invoke it. The Python
|
||||
# script is responsible for:
|
||||
# * tolerating a missing or empty hooks.json (starts from {})
|
||||
# * preserving unrelated hook entries on install
|
||||
# * preserving unrelated hook entries on uninstall
|
||||
# * recognising MemPalace entries by basename in the `command` field
|
||||
# * idempotent install (re-running does not duplicate entries)
|
||||
|
||||
# mktemp portability: pass an explicit absolute template so we sidestep
|
||||
# the BSD vs GNU difference in `-t` semantics (BSD treats it as a
|
||||
# prefix; GNU treats it as a template). Honour TMPDIR if set, fall
|
||||
# back to /tmp. gh-PR review caught the previous `-t` form as
|
||||
# non-portable.
|
||||
MERGE_PY="$(mktemp "${TMPDIR:-/tmp}/mempal-install-merge.XXXXXX")"
|
||||
trap 'rm -f "$MERGE_PY"' EXIT
|
||||
|
||||
cat > "$MERGE_PY" <<'PYEOF'
|
||||
"""hooks.json merge helper for hooks/cursor/install.sh.
|
||||
|
||||
Argv:
|
||||
sys.argv[1]: path to hooks.json (may not exist)
|
||||
sys.argv[2]: variant ("full" or "minimal")
|
||||
sys.argv[3]: uninstall flag ("1" or "0")
|
||||
sys.argv[4]: save_cmd absolute path
|
||||
sys.argv[5]: precompact_cmd absolute path
|
||||
sys.argv[6]: wake_cmd absolute path
|
||||
|
||||
Output: prints the merged JSON to stdout. Exits 2 on a malformed
|
||||
existing config (refuses to overwrite a broken file).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
target_file = sys.argv[1]
|
||||
variant = sys.argv[2]
|
||||
uninstall = sys.argv[3] == "1"
|
||||
save_cmd = sys.argv[4]
|
||||
precompact_cmd = sys.argv[5]
|
||||
wake_cmd = sys.argv[6]
|
||||
|
||||
# Recognise our entries by basename. The three filenames below are
|
||||
# the unique product-of-our-naming convention; any entry whose command
|
||||
# ends in one of them is treated as a MemPalace entry on
|
||||
# install (so we replace rather than duplicate it) and on uninstall
|
||||
# (so we remove it without touching unrelated entries). Matching on
|
||||
# basename rather than a full-path substring lets users pick any
|
||||
# --install-dir without breaking uninstall.
|
||||
MEMPAL_BASENAMES = (
|
||||
"mempal_save_hook_cursor.sh",
|
||||
"mempal_precompact_hook_cursor.sh",
|
||||
"mempal_wake_hook_cursor.sh",
|
||||
)
|
||||
|
||||
if os.path.exists(target_file):
|
||||
with open(target_file, "r", encoding="utf-8") as fh:
|
||||
try:
|
||||
cfg = json.load(fh)
|
||||
except Exception as exc:
|
||||
sys.stderr.write(
|
||||
"install.sh: existing %s is not valid JSON: %s\n"
|
||||
"Refusing to overwrite. Fix the file and retry.\n"
|
||||
% (target_file, exc)
|
||||
)
|
||||
sys.exit(2)
|
||||
else:
|
||||
cfg = {}
|
||||
|
||||
if not isinstance(cfg, dict):
|
||||
sys.stderr.write(
|
||||
"install.sh: %s top level must be a JSON object; got %s\n"
|
||||
% (target_file, type(cfg).__name__)
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
cfg.setdefault("version", 1)
|
||||
cfg.setdefault("hooks", {})
|
||||
if not isinstance(cfg["hooks"], dict):
|
||||
sys.stderr.write(
|
||||
"install.sh: %s 'hooks' must be a JSON object\n" % target_file
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def is_mempal_entry(entry):
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
cmd = entry.get("command", "")
|
||||
if not isinstance(cmd, str):
|
||||
return False
|
||||
# Match on basename so a customised --install-dir (e.g. /opt/...,
|
||||
# ~/.local/share/..., or anything with or without a leading dot)
|
||||
# still round-trips through uninstall.
|
||||
base = os.path.basename(cmd)
|
||||
return base in MEMPAL_BASENAMES
|
||||
|
||||
|
||||
def filter_mempal(entries):
|
||||
if not isinstance(entries, list):
|
||||
return entries
|
||||
return [e for e in entries if not is_mempal_entry(e)]
|
||||
|
||||
|
||||
def upsert(event, entry):
|
||||
existing = cfg["hooks"].get(event, [])
|
||||
if not isinstance(existing, list):
|
||||
sys.stderr.write(
|
||||
"install.sh: %s hooks[%s] must be a list\n" % (target_file, event)
|
||||
)
|
||||
sys.exit(2)
|
||||
cleaned = [e for e in existing if not is_mempal_entry(e)]
|
||||
cleaned.append(entry)
|
||||
cfg["hooks"][event] = cleaned
|
||||
|
||||
|
||||
if uninstall:
|
||||
for event in list(cfg["hooks"].keys()):
|
||||
cfg["hooks"][event] = filter_mempal(cfg["hooks"][event])
|
||||
if not cfg["hooks"][event]:
|
||||
del cfg["hooks"][event]
|
||||
else:
|
||||
upsert("stop", {"command": save_cmd, "loop_limit": 1})
|
||||
if variant == "full":
|
||||
upsert("preCompact", {"command": precompact_cmd})
|
||||
upsert("sessionStart", {"command": wake_cmd})
|
||||
|
||||
# Stable key order for the events MemPalace touches, then preserve
|
||||
# any unrelated event names in their original order so future Cursor
|
||||
# events we don't know about yet still round-trip.
|
||||
known_order = [
|
||||
"sessionStart",
|
||||
"stop",
|
||||
"preCompact",
|
||||
"sessionEnd",
|
||||
"preToolUse",
|
||||
"postToolUse",
|
||||
"postToolUseFailure",
|
||||
"subagentStart",
|
||||
"subagentStop",
|
||||
"beforeShellExecution",
|
||||
"afterShellExecution",
|
||||
"beforeMCPExecution",
|
||||
"afterMCPExecution",
|
||||
"beforeReadFile",
|
||||
"afterFileEdit",
|
||||
"beforeSubmitPrompt",
|
||||
"afterAgentResponse",
|
||||
"afterAgentThought",
|
||||
"beforeTabFileRead",
|
||||
"afterTabFileEdit",
|
||||
"workspaceOpen",
|
||||
]
|
||||
|
||||
ordered_hooks = {}
|
||||
for event in known_order:
|
||||
if event in cfg["hooks"]:
|
||||
ordered_hooks[event] = cfg["hooks"][event]
|
||||
for event, entries in cfg["hooks"].items():
|
||||
if event not in ordered_hooks:
|
||||
ordered_hooks[event] = entries
|
||||
cfg["hooks"] = ordered_hooks
|
||||
|
||||
print(json.dumps(cfg, indent=2, sort_keys=False))
|
||||
PYEOF
|
||||
|
||||
NEW_JSON="$("$PYTHON_BIN" "$MERGE_PY" \
|
||||
"$TARGET_FILE" "$VARIANT" "$UNINSTALL" \
|
||||
"$SAVE_CMD" "$PRECOMPACT_CMD" "$WAKE_CMD")"
|
||||
|
||||
# ── Step 3: emit, write, or remove ────────────────────────────────
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf 'install.sh: --dry-run; would write to %s\n' "$TARGET_FILE" >&2
|
||||
printf '%s\n' "$NEW_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$TARGET_DIR"
|
||||
|
||||
# If --uninstall left an empty hooks object AND no other top-level
|
||||
# keys beyond version, remove the file entirely so the user's
|
||||
# `.cursor/` directory does not accumulate orphan configs.
|
||||
if [ "$UNINSTALL" -eq 1 ]; then
|
||||
# Inline the emptiness check via `python -c '...'` rather than a
|
||||
# temp .py file. The body is short enough that a tmpfile is pure
|
||||
# overhead, and removing the tmpfile eliminates a small leak
|
||||
# window if the script is interrupted between mktemp and rm -f.
|
||||
# gh-PR review suggested this simplification.
|
||||
NON_EMPTY="$(printf '%s' "$NEW_JSON" | "$PYTHON_BIN" -c '
|
||||
import json, sys
|
||||
cfg = json.load(sys.stdin)
|
||||
hooks = cfg.get("hooks", {})
|
||||
extras = [k for k in cfg.keys() if k not in ("version", "hooks")]
|
||||
print("1" if (hooks or extras) else "0")
|
||||
')"
|
||||
if [ "$NON_EMPTY" = "0" ] && [ -f "$TARGET_FILE" ]; then
|
||||
rm -f "$TARGET_FILE"
|
||||
printf 'install.sh: removed empty %s\n' "$TARGET_FILE" >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
TMP_FILE="${TARGET_FILE}.tmp.$$"
|
||||
printf '%s\n' "$NEW_JSON" > "$TMP_FILE"
|
||||
mv "$TMP_FILE" "$TARGET_FILE"
|
||||
|
||||
if [ "$UNINSTALL" -eq 1 ]; then
|
||||
printf 'install.sh: removed MemPalace entries from %s\n' "$TARGET_FILE" >&2
|
||||
else
|
||||
printf 'install.sh: wrote %s\n' "$TARGET_FILE" >&2
|
||||
printf 'install.sh: restart Cursor (or wait for it to reload hooks.json)\n' >&2
|
||||
fi
|
||||
@@ -0,0 +1,490 @@
|
||||
# shellcheck shell=bash
|
||||
# MEMPALACE CURSOR HOOK — shared helpers
|
||||
#
|
||||
# Sourced by the three Cursor hooks (stop / preCompact / sessionStart).
|
||||
# Mirrors the conventions of the existing Claude Code hook scripts
|
||||
# (hooks/mempal_save_hook.sh, hooks/mempal_precompact_hook.sh) so a
|
||||
# user who already debugs one knows how to debug the other:
|
||||
#
|
||||
# * STATE_DIR layout under ~/.mempalace/hook_state/
|
||||
# * MEMPAL_PYTHON resolution order (override → $PATH → bare python3)
|
||||
# * MEMPALACE_HOOKS_AUTO_SAVE=false kill switch (config.json fallback)
|
||||
# * sentinel-guarded Python parser via `sed -n 'Np'` (bash 3.2 safe)
|
||||
# * fail-open on internal errors: emit `{}` and log, never crash the
|
||||
# hook host
|
||||
#
|
||||
# Cursor-specific additions on top of that contract:
|
||||
#
|
||||
# * MEMPAL_DISABLE_HOOK=1 as an additional kill-switch alias
|
||||
# * MEMPAL_STATE_DIR env override for the state directory
|
||||
# * conversation_id (Cursor's stable per-conversation ID) replaces
|
||||
# Claude Code's session_id in the counter file names — Cursor `stop`
|
||||
# events do not carry a session_id, only conversation_id
|
||||
# * loop_count is the loop-prevention signal in place of Claude Code's
|
||||
# stop_hook_active flag (Cursor docs, "stop" event)
|
||||
#
|
||||
# This file is sourced, not executed, so it intentionally has no
|
||||
# shebang. The `# shellcheck shell=bash` directive above tells
|
||||
# shellcheck to treat it as bash when run standalone.
|
||||
|
||||
# ── State directory + log path ────────────────────────────────────────
|
||||
#
|
||||
# Honour MEMPAL_STATE_DIR (additive override introduced for Cursor)
|
||||
# while keeping the default identical to the Claude Code hooks so a
|
||||
# user running both keeps a single state directory.
|
||||
MEMPAL_STATE_DIR="${MEMPAL_STATE_DIR:-$HOME/.mempalace/hook_state}"
|
||||
mkdir -p "$MEMPAL_STATE_DIR" 2>/dev/null
|
||||
MEMPAL_CURSOR_LOG="$MEMPAL_STATE_DIR/cursor_hook.log"
|
||||
|
||||
# ── Python interpreter resolution ─────────────────────────────────────
|
||||
#
|
||||
# Same contract as the Claude Code hooks:
|
||||
# 1. $MEMPAL_PYTHON — explicit user override (absolute path)
|
||||
# 2. $(command -v python3) — first python3 on the hook's PATH
|
||||
# 3. bare "python3" — last-resort fallback
|
||||
mempal_resolve_python() {
|
||||
local p="${MEMPAL_PYTHON:-}"
|
||||
if [ -n "$p" ] && [ -x "$p" ]; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
p="$(command -v python3 2>/dev/null || true)"
|
||||
if [ -n "$p" ]; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "python3"
|
||||
}
|
||||
MEMPAL_PYTHON_BIN="$(mempal_resolve_python)"
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# Lines are `[ISO8601Z] [event=...] [conv=...] message`. ISO8601 keeps
|
||||
# the format greppable across timezones (the Claude Code log uses
|
||||
# %H:%M:%S which loses the date — we improve on that here without
|
||||
# changing the existing log file).
|
||||
mempal_log() {
|
||||
local event="${1:-?}"
|
||||
local conv="${2:-unknown}"
|
||||
local msg="${3:-}"
|
||||
local ts
|
||||
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
printf '[%s] [event=%s] [conv=%s] %s\n' "$ts" "$event" "$conv" "$msg" \
|
||||
>> "$MEMPAL_CURSOR_LOG" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Kill switch ───────────────────────────────────────────────────────
|
||||
#
|
||||
# Disabled if ANY of:
|
||||
# * MEMPAL_DISABLE_HOOK is a truthy string (Cursor-prompt addition)
|
||||
# * MEMPALACE_HOOKS_AUTO_SAVE is false/0/no (Claude Code convention)
|
||||
# * ~/.mempalace/config.json has hooks.auto_save == false
|
||||
#
|
||||
# Returns 0 (true in shell) when disabled, 1 when enabled.
|
||||
mempal_is_disabled() {
|
||||
case "${MEMPAL_DISABLE_HOOK:-}" in
|
||||
1|true|yes|on) return 0 ;;
|
||||
esac
|
||||
case "${MEMPALACE_HOOKS_AUTO_SAVE:-}" in
|
||||
false|0|no|off) return 0 ;;
|
||||
esac
|
||||
local cfg="$HOME/.mempalace/config.json"
|
||||
if [ -f "$cfg" ]; then
|
||||
local result
|
||||
# Use python -c '...' with the config path as argv[1] rather
|
||||
# than a heredoc. A heredoc body that contains parens inside a
|
||||
# $(...) command substitution trips the bash 3.2.57 parser
|
||||
# bug (macOS /bin/bash default) — gh-PR review caught this.
|
||||
# The -c form is also consistent with mempal_parse_stdin below.
|
||||
result="$("$MEMPAL_PYTHON_BIN" -c '
|
||||
import json, sys
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
cfg = json.load(f)
|
||||
print(str(cfg.get("hooks", {}).get("auto_save", True)).lower())
|
||||
except Exception:
|
||||
print("true")
|
||||
' "$cfg" 2>/dev/null)"
|
||||
if [ "$result" = "false" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Stdin parser ──────────────────────────────────────────────────────
|
||||
#
|
||||
# Reads Cursor's hook JSON from $1 and exports:
|
||||
# MEMPAL_CONV_ID — conversation_id, falls back to "unknown"
|
||||
# MEMPAL_LOOP_COUNT — integer (0 if absent / non-numeric)
|
||||
# MEMPAL_TRANSCRIPT — transcript_path, may be empty
|
||||
# MEMPAL_WORKSPACE — first workspace_roots entry, falls back to
|
||||
# CURSOR_PROJECT_DIR env var, then $PWD
|
||||
# MEMPAL_TRIGGER — preCompact trigger ("auto" | "manual"), empty otherwise
|
||||
# MEMPAL_STATUS — stop status ("completed" | "aborted" | "error"),
|
||||
# empty otherwise
|
||||
# MEMPAL_PARSE_OK — "1" if parser ran cleanly, "0" otherwise
|
||||
#
|
||||
# Uses the same sentinel + `sed -n 'Np'` extraction as the Claude Code
|
||||
# hooks for bash 3.2 compatibility (mapfile/readarray are unavailable
|
||||
# on macOS /bin/bash 3.2.57; #1440 regression). Each line of output is
|
||||
# pre-sanitised by the Python side to a shell-safe character set.
|
||||
mempal_parse_stdin() {
|
||||
local input="${1:-}"
|
||||
local parsed
|
||||
# We invoke Python via -c with a single-quoted multi-line string
|
||||
# rather than ``python3 - <<'PYEOF'`` because the heredoc form
|
||||
# would shadow Python's stdin with the heredoc body, leaving
|
||||
# ``json.load(sys.stdin)`` to read nothing and silently fail. The
|
||||
# parser body deliberately uses only double-quoted Python strings
|
||||
# so the surrounding bash single-quote is safe verbatim, and uses
|
||||
# only the shell-safe character set (alphanumeric, underscore,
|
||||
# dash, slash, dot, tilde) matching the Claude Code hook's
|
||||
# sanitiser so a hostile transcript_path cannot splice
|
||||
# metacharacters into the parsed output.
|
||||
parsed="$(
|
||||
umask 077
|
||||
printf '%s' "$input" | "$MEMPAL_PYTHON_BIN" -c '
|
||||
import json, re, sys
|
||||
|
||||
def safe_str(value):
|
||||
return re.sub(r"[^a-zA-Z0-9_/.\-~]", "", str(value or ""))
|
||||
|
||||
def safe_int(value):
|
||||
try:
|
||||
return str(int(value))
|
||||
except (TypeError, ValueError):
|
||||
return "0"
|
||||
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
if not isinstance(data, dict):
|
||||
sys.exit(1)
|
||||
|
||||
conv = safe_str(data.get("conversation_id") or data.get("session_id"))
|
||||
loop_count = safe_int(data.get("loop_count", 0))
|
||||
transcript = safe_str(data.get("transcript_path", ""))
|
||||
trigger = safe_str(data.get("trigger", ""))
|
||||
status = safe_str(data.get("status", ""))
|
||||
|
||||
roots = data.get("workspace_roots") or []
|
||||
workspace = ""
|
||||
if isinstance(roots, list) and roots:
|
||||
workspace = safe_str(roots[0])
|
||||
|
||||
print("__MEMPAL_PARSE_OK__")
|
||||
print(conv)
|
||||
print(loop_count)
|
||||
print(transcript)
|
||||
print(workspace)
|
||||
print(trigger)
|
||||
print(status)
|
||||
' 2>"$MEMPAL_STATE_DIR/cursor_last_python_err.log"
|
||||
)"
|
||||
|
||||
# Drop empty stderr capture on success; lock it to 0600 on failure
|
||||
# (mirrors the privacy contract in the Claude Code hooks — the
|
||||
# traceback can echo back transcript_path / home layout).
|
||||
if [ -s "$MEMPAL_STATE_DIR/cursor_last_python_err.log" ]; then
|
||||
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
|
||||
else
|
||||
rm -f "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
|
||||
fi
|
||||
|
||||
local marker
|
||||
marker="$(printf '%s\n' "$parsed" | sed -n '1p')"
|
||||
if [ "$marker" = "__MEMPAL_PARSE_OK__" ]; then
|
||||
MEMPAL_PARSE_OK="1"
|
||||
MEMPAL_CONV_ID="$(printf '%s\n' "$parsed" | sed -n '2p')"
|
||||
MEMPAL_LOOP_COUNT="$(printf '%s\n' "$parsed" | sed -n '3p')"
|
||||
MEMPAL_TRANSCRIPT="$(printf '%s\n' "$parsed" | sed -n '4p')"
|
||||
MEMPAL_WORKSPACE="$(printf '%s\n' "$parsed" | sed -n '5p')"
|
||||
MEMPAL_TRIGGER="$(printf '%s\n' "$parsed" | sed -n '6p')"
|
||||
MEMPAL_STATUS="$(printf '%s\n' "$parsed" | sed -n '7p')"
|
||||
else
|
||||
MEMPAL_PARSE_OK="0"
|
||||
MEMPAL_CONV_ID=""
|
||||
MEMPAL_LOOP_COUNT="0"
|
||||
MEMPAL_TRANSCRIPT=""
|
||||
MEMPAL_WORKSPACE=""
|
||||
MEMPAL_TRIGGER=""
|
||||
MEMPAL_STATUS=""
|
||||
fi
|
||||
|
||||
# Defaults and environment fallbacks. The Cursor docs guarantee
|
||||
# CURSOR_TRANSCRIPT_PATH and CURSOR_PROJECT_DIR env vars are set
|
||||
# for every hook execution; if JSON parsing failed for any reason
|
||||
# (sentinel missing, malformed payload, missing interpreter) we
|
||||
# still have a usable workspace.
|
||||
MEMPAL_CONV_ID="${MEMPAL_CONV_ID:-unknown}"
|
||||
case "$MEMPAL_LOOP_COUNT" in
|
||||
''|*[!0-9]*) MEMPAL_LOOP_COUNT="0" ;;
|
||||
esac
|
||||
if [ -z "$MEMPAL_TRANSCRIPT" ] && [ -n "${CURSOR_TRANSCRIPT_PATH:-}" ]; then
|
||||
MEMPAL_TRANSCRIPT="${CURSOR_TRANSCRIPT_PATH}"
|
||||
fi
|
||||
if [ -z "$MEMPAL_WORKSPACE" ]; then
|
||||
if [ -n "${CURSOR_PROJECT_DIR:-}" ]; then
|
||||
MEMPAL_WORKSPACE="${CURSOR_PROJECT_DIR}"
|
||||
elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
|
||||
MEMPAL_WORKSPACE="${CLAUDE_PROJECT_DIR}"
|
||||
else
|
||||
MEMPAL_WORKSPACE="${PWD:-/}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Expand a leading ~ in the transcript path so downstream
|
||||
# ``[ -f "$path" ]`` checks resolve correctly.
|
||||
case "$MEMPAL_TRANSCRIPT" in
|
||||
'~/'*) MEMPAL_TRANSCRIPT="$HOME/${MEMPAL_TRANSCRIPT#~/}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Defense-in-depth: dump unparseable stdin ──────────────────────────
|
||||
#
|
||||
# Same shape as the Claude Code hooks' last_input.log: bounded to 4096
|
||||
# bytes, overwritten (never appended) so a misconfiguration loop cannot
|
||||
# grow disk usage, 0600 perms because the dump mirrors the raw hook
|
||||
# payload (transcript_path reveals the user's home + project layout).
|
||||
mempal_dump_bad_input() {
|
||||
local input="${1:-}"
|
||||
if [ -z "$input" ]; then
|
||||
return 0
|
||||
fi
|
||||
mempal_log "${2:-?}" "${MEMPAL_CONV_ID:-unknown}" \
|
||||
"WARN: input parse failed (sentinel missing); see $MEMPAL_STATE_DIR/cursor_last_input.log + cursor_last_python_err.log"
|
||||
(
|
||||
umask 077
|
||||
printf '%s' "$input" | head -c 4096 > "$MEMPAL_STATE_DIR/cursor_last_input.log"
|
||||
)
|
||||
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_input.log" 2>/dev/null
|
||||
}
|
||||
|
||||
# ── Counter helpers ───────────────────────────────────────────────────
|
||||
#
|
||||
# One counter file per conversation_id. Atomic write via temp file
|
||||
# inside the same directory (rename is atomic on POSIX) so concurrent
|
||||
# hook invocations cannot half-write the file. Read tolerates a
|
||||
# corrupted or empty file by returning 0, never crashing.
|
||||
_mempal_counter_path() {
|
||||
local conv="${1:-unknown}"
|
||||
# Sanitise the conv id one more time: it has already been through
|
||||
# the Python sanitiser, but be defensive in case a caller passes a
|
||||
# raw string. Strip any character outside [a-zA-Z0-9_.-].
|
||||
local safe_conv
|
||||
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
|
||||
if [ -z "$safe_conv" ]; then
|
||||
safe_conv="unknown"
|
||||
fi
|
||||
printf '%s/cursor_%s.count' "$MEMPAL_STATE_DIR" "$safe_conv"
|
||||
}
|
||||
|
||||
mempal_read_counter() {
|
||||
local path="$1"
|
||||
if [ ! -f "$path" ]; then
|
||||
printf '0'
|
||||
return 0
|
||||
fi
|
||||
local raw
|
||||
raw="$(cat "$path" 2>/dev/null)"
|
||||
case "$raw" in
|
||||
''|*[!0-9]*) printf '0' ;;
|
||||
*) printf '%s' "$raw" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
mempal_write_counter_atomic() {
|
||||
local path="$1"
|
||||
local value="$2"
|
||||
case "$value" in
|
||||
''|*[!0-9]*) value="0" ;;
|
||||
esac
|
||||
local tmp="${path}.tmp.$$"
|
||||
printf '%s' "$value" > "$tmp" 2>/dev/null || return 1
|
||||
mv "$tmp" "$path" 2>/dev/null || {
|
||||
rm -f "$tmp" 2>/dev/null
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# ── Pending-save marker ───────────────────────────────────────────────
|
||||
#
|
||||
# Dropped by the preCompact hook (which cannot itself emit a
|
||||
# followup_message — Cursor's preCompact is observational-only) and
|
||||
# consumed by the next stop invocation so the LLM still gets a diary
|
||||
# nudge after compaction.
|
||||
_mempal_pending_path() {
|
||||
local conv="${1:-unknown}"
|
||||
local safe_conv
|
||||
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
|
||||
if [ -z "$safe_conv" ]; then
|
||||
safe_conv="unknown"
|
||||
fi
|
||||
printf '%s/cursor_%s.pending' "$MEMPAL_STATE_DIR" "$safe_conv"
|
||||
}
|
||||
|
||||
mempal_set_pending() {
|
||||
local conv="${1:-unknown}"
|
||||
local path
|
||||
path="$(_mempal_pending_path "$conv")"
|
||||
: > "$path" 2>/dev/null || return 1
|
||||
chmod 600 "$path" 2>/dev/null
|
||||
}
|
||||
|
||||
mempal_consume_pending() {
|
||||
local conv="${1:-unknown}"
|
||||
local path
|
||||
path="$(_mempal_pending_path "$conv")"
|
||||
if [ -f "$path" ]; then
|
||||
rm -f "$path" 2>/dev/null
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── State-file TTL ────────────────────────────────────────────────────
|
||||
#
|
||||
# Per-conversation state artifacts (cursor_<conv>.count and
|
||||
# cursor_<conv>.pending) accumulate one set per conversation and are
|
||||
# never otherwise removed (igorls review, PR #1632 — unbounded state
|
||||
# growth). Reads MEMPAL_STATE_TTL_DAYS (default 30), validated
|
||||
# digits-only and leading-zero-stripped (mirrors the SAVE_INTERVAL
|
||||
# sanitiser) so `find -mtime` never sees a bad or octal token. Empty or
|
||||
# non-numeric floors to 30; a value of 0 means "sweep everything older
|
||||
# than today".
|
||||
mempal_state_ttl_days() {
|
||||
local raw="${MEMPAL_STATE_TTL_DAYS:-30}"
|
||||
case "$raw" in
|
||||
''|*[!0-9]*) printf '30'; return 0 ;;
|
||||
esac
|
||||
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
|
||||
raw="${raw#0}"
|
||||
done
|
||||
printf '%s' "$raw"
|
||||
}
|
||||
|
||||
# ── Stale state GC ────────────────────────────────────────────────────
|
||||
#
|
||||
# Opportunistic sweep of per-conversation Cursor state older than the
|
||||
# TTL. Throttled to at most once per 24h via the cursor_last_sweep
|
||||
# marker, so it costs a single mtime comparison on the vast majority of
|
||||
# fires. When it does run, two `find` passes remove the stale counter
|
||||
# files and pending markers.
|
||||
#
|
||||
# The globs are Cursor-specific and suffix-anchored (cursor_*.count,
|
||||
# cursor_*.pending), so the shared logs (cursor_hook.log,
|
||||
# cursor_last_input.log, cursor_last_python_err.log), the
|
||||
# cursor_last_sweep marker itself, and any antigravity_*/Claude state
|
||||
# sharing the same directory are never touched. BSD find (macOS default)
|
||||
# and GNU find both accept -maxdepth, -mtime +N, and -exec ... +.
|
||||
#
|
||||
# Fail-open: every step is best-effort; a missing state dir, a find that
|
||||
# errors, or a permission problem must never abort the caller.
|
||||
mempal_gc_stale_state() {
|
||||
[ -d "$MEMPAL_STATE_DIR" ] || return 0
|
||||
|
||||
local marker="$MEMPAL_STATE_DIR/cursor_last_sweep"
|
||||
if [ -f "$marker" ]; then
|
||||
local mtime now
|
||||
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$marker" 2>/dev/null) \
|
||||
&& now=$(date '+%s' 2>/dev/null) \
|
||||
&& [ -n "$mtime" ] \
|
||||
&& [ "$((now - mtime))" -lt 86400 ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Touch the marker first so a crash mid-sweep still throttles the
|
||||
# next fire (better to skip a sweep than to hammer the disk).
|
||||
: > "$marker" 2>/dev/null
|
||||
|
||||
local ttl
|
||||
ttl=$(mempal_state_ttl_days)
|
||||
|
||||
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
|
||||
-name 'cursor_*.count' -mtime +"$ttl" \
|
||||
-exec rm -f {} + 2>/dev/null
|
||||
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
|
||||
-name 'cursor_*.pending' -mtime +"$ttl" \
|
||||
-exec rm -f {} + 2>/dev/null
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Workspace → wing inference ────────────────────────────────────────
|
||||
#
|
||||
# basename(workspace_root), normalised to [a-z0-9_-]. Edge cases:
|
||||
# / → "root"
|
||||
# /path/ → trailing slash stripped, then basename
|
||||
# "/foo bar/" → "foo_bar" (spaces collapsed to underscores)
|
||||
# "" → "cursor_session"
|
||||
# "C:\\proj" → "proj" (Windows-style path; basename via tr fallback)
|
||||
#
|
||||
# We intentionally keep this in pure bash + POSIX tools so the
|
||||
# inference is identical across the hook scripts and the test suite
|
||||
# can target it as a function via `bash -c 'source ...; ...'`.
|
||||
mempal_infer_wing() {
|
||||
local raw="${1:-}"
|
||||
if [ -z "$raw" ]; then
|
||||
printf 'cursor_session'
|
||||
return 0
|
||||
fi
|
||||
# Strip trailing slashes (but preserve the lone "/" case).
|
||||
while [ "$raw" != "/" ] && [ "${raw%/}" != "$raw" ]; do
|
||||
raw="${raw%/}"
|
||||
done
|
||||
if [ "$raw" = "/" ]; then
|
||||
printf 'root'
|
||||
return 0
|
||||
fi
|
||||
local base="${raw##*/}"
|
||||
# On Windows-style paths with backslashes, fall back to splitting
|
||||
# on backslash too so we don't return the whole path verbatim.
|
||||
case "$base" in
|
||||
*\\*) base="${base##*\\}" ;;
|
||||
esac
|
||||
# Lowercase + replace anything outside [a-z0-9_-] with underscore.
|
||||
# Collapse runs of underscores so "foo bar" doesn't become
|
||||
# "foo__bar".
|
||||
base="$(printf '%s' "$base" \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| tr -c 'a-z0-9_-' '_' \
|
||||
| tr -s '_' \
|
||||
| sed 's/^_//; s/_$//')"
|
||||
if [ -z "$base" ]; then
|
||||
printf 'cursor_session'
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "$base"
|
||||
}
|
||||
|
||||
# ── Transcript path validation ────────────────────────────────────────
|
||||
#
|
||||
# Mirrors hooks/mempal_save_hook.sh::is_valid_transcript_path so the
|
||||
# Cursor and Claude Code hooks reject the same shapes:
|
||||
# * non-empty
|
||||
# * .json or .jsonl suffix
|
||||
# * no .. traversal segments
|
||||
mempal_is_valid_transcript() {
|
||||
local path="${1:-}"
|
||||
[ -n "$path" ] || return 1
|
||||
case "$path" in
|
||||
*.json|*.jsonl) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
case "/$path/" in
|
||||
*/../*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── JSON emit ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# Final stdout write. Uses ``printf '%s'`` instead of ``echo`` because
|
||||
# echo interprets ``-n``/``-e``/``-E`` as flags and varies in backslash
|
||||
# handling between builtin and /bin/echo (xpg_echo shopt). Matches the
|
||||
# Claude Code hook's documented rationale.
|
||||
mempal_emit() {
|
||||
printf '%s\n' "${1:-{\}}"
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE CURSOR PRE-COMPACT HOOK — Snapshot transcript before compaction
|
||||
#
|
||||
# Cursor "preCompact" hook. Cursor's preCompact is documented as
|
||||
# OBSERVATIONAL ONLY (cursor.com/docs/hooks.md fetched 2026-05-27):
|
||||
#
|
||||
# * It cannot block compaction.
|
||||
# * Its only output field is `user_message` (no `followup_message`,
|
||||
# no `decision: block`).
|
||||
#
|
||||
# So unlike the Claude Code PreCompact hook (which can block the AI
|
||||
# and force a save before compaction proceeds), the Cursor preCompact
|
||||
# hook can only do two useful things at this moment:
|
||||
#
|
||||
# 1. Run `mempalace mine` SYNCHRONOUSLY against the transcript file
|
||||
# so whatever Cursor's transcript contains is ingested BEFORE
|
||||
# Cursor summarises the conversation — zero LLM cost, no agent
|
||||
# interaction needed. NOTE: this is BEST-EFFORT for Cursor.
|
||||
# Cursor's transcript format is undocumented and normalize.py has
|
||||
# no Cursor parser, so this does not yet produce clean verbatim
|
||||
# drawers; it is a safety net, not the primary capture path.
|
||||
#
|
||||
# 2. Drop a `.pending` marker file keyed on conversation_id. The
|
||||
# next `stop` hook reads that marker and forces a save followup
|
||||
# regardless of its counter, so the AI still gets a "write a
|
||||
# diary entry now" nudge on the very next turn. THIS followup is
|
||||
# the load-bearing verbatim-capture path for Cursor (the agent
|
||||
# files its own in-context verbatim quotes via the MCP tools).
|
||||
#
|
||||
# === INSTALL ===
|
||||
#
|
||||
# Add to ~/.cursor/hooks.json (or .cursor/hooks.json for project
|
||||
# scope) under "preCompact":
|
||||
#
|
||||
# {
|
||||
# "version": 1,
|
||||
# "hooks": {
|
||||
# "preCompact": [
|
||||
# { "command": "/absolute/path/to/mempal_precompact_hook_cursor.sh" }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# No loop_limit is needed; preCompact is not a looping hook.
|
||||
|
||||
_mempal_self="${BASH_SOURCE[0]:-$0}"
|
||||
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
. "$_mempal_dir/lib/common.sh"
|
||||
|
||||
# Optional additional project directory to mine before compaction
|
||||
# (parity with the Claude Code hook's MEMPAL_DIR knob).
|
||||
MEMPAL_DIR="${MEMPAL_DIR:-}"
|
||||
|
||||
if mempal_is_disabled; then
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Opportunistic, daily-throttled GC of stale per-conversation state.
|
||||
# Placed after the kill switch so a disabled hook touches nothing.
|
||||
mempal_gc_stale_state
|
||||
|
||||
INPUT="$(cat)"
|
||||
mempal_parse_stdin "$INPUT"
|
||||
|
||||
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
|
||||
mempal_dump_bad_input "$INPUT" "preCompact"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"trigger=${MEMPAL_TRIGGER:-?} transcript=$MEMPAL_TRANSCRIPT"
|
||||
|
||||
# ── Synchronous mine ──────────────────────────────────────────────
|
||||
#
|
||||
# This intentionally blocks the hook. Compaction is irreversible —
|
||||
# once Cursor summarises the conversation we cannot get the verbatim
|
||||
# text back — so we must finish ingesting before returning. Background
|
||||
# mining would race the compaction and lose data.
|
||||
#
|
||||
# TIMEOUT TRADEOFF (igorls review, PR #1632): on a very large transcript
|
||||
# this synchronous mine can exceed Cursor's per-hook timeout, in which
|
||||
# case Cursor kills the process mid-mine. That is acceptable and safe
|
||||
# here: `mempalace mine` is incremental and append-only (a crash mid-
|
||||
# operation leaves the existing palace untouched — see CLAUDE.md
|
||||
# "Incremental only"), so a killed mine simply resumes on the next mine
|
||||
# invocation rather than corrupting the palace. We deliberately do NOT
|
||||
# wrap this in a shorter timeout, because truncating the mine would
|
||||
# trade a recoverable partial-ingest for guaranteed silent data loss
|
||||
# right before the irreversible compaction. The pending-save marker
|
||||
# below is the backstop: the next `stop` hook re-mines and nudges a
|
||||
# verbatim save regardless of whether this mine completed.
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
if mempal_is_valid_transcript "$MEMPAL_TRANSCRIPT" \
|
||||
&& [ -f "$MEMPAL_TRANSCRIPT" ]; then
|
||||
mempalace mine "$(dirname "$MEMPAL_TRANSCRIPT")" --mode convos \
|
||||
>> "$MEMPAL_CURSOR_LOG" 2>&1 || \
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"WARN: mempalace mine convos returned non-zero"
|
||||
elif [ -n "$MEMPAL_TRANSCRIPT" ]; then
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"skipping invalid transcript path: $MEMPAL_TRANSCRIPT"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$MEMPAL_CURSOR_LOG" 2>&1 || \
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"WARN: mempalace mine projects returned non-zero"
|
||||
fi
|
||||
else
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"mempalace CLI not on PATH; skipping synchronous mine"
|
||||
fi
|
||||
|
||||
# ── Drop the pending-save marker ──────────────────────────────────
|
||||
mempal_set_pending "$MEMPAL_CONV_ID" || \
|
||||
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
|
||||
"WARN: could not write pending-save marker"
|
||||
|
||||
# Surface a short user-visible note that compaction is about to
|
||||
# happen and we've already captured the verbatim text. user_message
|
||||
# is the only output field Cursor's preCompact accepts.
|
||||
"$MEMPAL_PYTHON_BIN" -c '
|
||||
import json
|
||||
print(json.dumps({
|
||||
"user_message": (
|
||||
"MemPalace: transcript snapshotted before compaction. "
|
||||
"A diary nudge is queued for the next agent turn."
|
||||
)
|
||||
}))
|
||||
'
|
||||
Executable
+269
@@ -0,0 +1,269 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE CURSOR SAVE HOOK — Auto-save every N stop events
|
||||
#
|
||||
# Cursor "stop" hook. After every agent loop ends, this hook:
|
||||
# 1. Counts stop invocations per conversation_id (each stop ≈ one
|
||||
# assistant turn ≈ roughly one user message — see plan rationale).
|
||||
# 2. Every SAVE_INTERVAL stops, returns a followup_message telling
|
||||
# the agent to file the session into MemPalace and write a diary
|
||||
# entry. Cursor auto-submits that as the next user message.
|
||||
# 3. On the next stop, loop_count > 0 so we let the agent finish
|
||||
# without re-firing — Cursor's loop_count is the equivalent of
|
||||
# Claude Code's stop_hook_active flag.
|
||||
# 4. If the preCompact hook has left a `.pending` marker, force a
|
||||
# save followup regardless of the counter and clear the marker.
|
||||
#
|
||||
# === WHY THE FOLLOWUP FIRES BY DEFAULT (differs from the Claude hook) ===
|
||||
#
|
||||
# The Claude Code hook (hooks/mempal_save_hook.sh) is SILENT by default:
|
||||
# its background `mempalace mine --mode convos` captures the verbatim
|
||||
# transcript on its own, and the LLM-driven diary nudge is opt-IN behind
|
||||
# MEMPAL_VERBOSE. That works because mempalace/normalize.py has a Claude
|
||||
# Code JSONL parser.
|
||||
#
|
||||
# Cursor is different. Cursor's transcript format is undocumented (see
|
||||
# STDIN_SHAPE.md) and normalize.py has NO Cursor parser, so the
|
||||
# background mine below is BEST-EFFORT only — it does not yet yield clean
|
||||
# verbatim drawers for Cursor. The followup_message is therefore the
|
||||
# load-bearing verbatim-capture path: it drives the agent to call
|
||||
# mempalace_add_drawer / mempalace_diary_write from its in-context memory.
|
||||
# That is why it is ON by default here — silencing it by default would
|
||||
# leave a default Cursor install capturing nothing.
|
||||
#
|
||||
# Users who want the Claude-style "zero tokens in the chat window"
|
||||
# behaviour can silence the followup (see MEMPAL_CURSOR_SILENT /
|
||||
# MEMPAL_VERBOSE below). Once normalize.py learns to read Cursor
|
||||
# transcripts, this default should flip to silent to match Claude.
|
||||
#
|
||||
# Companion files in this directory:
|
||||
# * lib/common.sh — shared helpers (sourced)
|
||||
# * mempal_precompact_hook_cursor.sh — preCompact event
|
||||
# * mempal_wake_hook_cursor.sh — sessionStart event
|
||||
#
|
||||
# === INSTALL ===
|
||||
#
|
||||
# Recommended path: run `hooks/cursor/install.sh` from a cloned repo,
|
||||
# which copies the scripts to ~/.mempalace/hooks/cursor/ and merges
|
||||
# the wiring into your ~/.cursor/hooks.json. See hooks/cursor/README.md
|
||||
# for the full walkthrough, or website/guide/cursor-hooks.md for the
|
||||
# rendered version.
|
||||
#
|
||||
# Manual wiring (user scope: ~/.cursor/hooks.json):
|
||||
#
|
||||
# {
|
||||
# "version": 1,
|
||||
# "hooks": {
|
||||
# "stop": [
|
||||
# {
|
||||
# "command": "/absolute/path/to/mempal_save_hook_cursor.sh",
|
||||
# "loop_limit": 1
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# The `loop_limit: 1` cap is defense-in-depth — even if our own
|
||||
# loop_count check below regresses, Cursor itself will stop emitting
|
||||
# our followup after one auto-iteration.
|
||||
#
|
||||
# === KILL SWITCHES ===
|
||||
#
|
||||
# MEMPAL_DISABLE_HOOK=1 — Cursor-prompt addition
|
||||
# MEMPALACE_HOOKS_AUTO_SAVE=false — matches the Claude Code hooks
|
||||
# ~/.mempalace/config.json "hooks.auto_save": false
|
||||
#
|
||||
# Any one of these short-circuits the hook to `{}` and exits 0.
|
||||
|
||||
# Resolve the directory this script lives in so we can source the
|
||||
# sibling lib/common.sh whether the user invoked us by absolute path,
|
||||
# by relative path, or via a symlink.
|
||||
_mempal_self="${BASH_SOURCE[0]:-$0}"
|
||||
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
. "$_mempal_dir/lib/common.sh"
|
||||
|
||||
SAVE_INTERVAL="${MEMPAL_SAVE_INTERVAL:-15}"
|
||||
# Coerce empty, non-numeric, AND zero to the default. SAVE_INTERVAL=0
|
||||
# would otherwise crash bash on the modulo check below ($((NEXT % 0))
|
||||
# is "division by 0"). gh-PR review caught this edge case.
|
||||
case "$SAVE_INTERVAL" in
|
||||
''|*[!0-9]*|0) SAVE_INTERVAL=15 ;;
|
||||
esac
|
||||
|
||||
# Optional additional project directory to mine on save (parity with
|
||||
# the Claude Code hook's MEMPAL_DIR knob — purely additive, never an
|
||||
# override for the transcript mine).
|
||||
MEMPAL_DIR="${MEMPAL_DIR:-}"
|
||||
|
||||
# ── Followup opt-out ──────────────────────────────────────────────
|
||||
#
|
||||
# Returns 0 (true) when the user has asked to suppress the
|
||||
# followup_message. See the header comment for why the followup is ON
|
||||
# by default for Cursor. When silenced, the hook still runs the
|
||||
# best-effort background mine and still maintains its counters/markers
|
||||
# — it just emits `{}` instead of a followup. Two equivalent signals:
|
||||
# * MEMPAL_CURSOR_SILENT=1|true|yes|on — dedicated Cursor opt-out
|
||||
# * MEMPAL_VERBOSE=false|0|no|off — cross-hook silence signal
|
||||
# (mirror-image of the Claude hook, where MEMPAL_VERBOSE=true is
|
||||
# what turns its diary nudge ON)
|
||||
mempal_followup_silenced() {
|
||||
case "${MEMPAL_CURSOR_SILENT:-}" in
|
||||
1|true|yes|on) return 0 ;;
|
||||
esac
|
||||
case "${MEMPAL_VERBOSE:-}" in
|
||||
false|0|no|off) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Kill switch — emit `{}` so Cursor proceeds with normal stop.
|
||||
if mempal_is_disabled; then
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Opportunistic, daily-throttled GC of stale per-conversation state.
|
||||
# Placed after the kill switch so a disabled hook touches nothing.
|
||||
mempal_gc_stale_state
|
||||
|
||||
INPUT="$(cat)"
|
||||
mempal_parse_stdin "$INPUT"
|
||||
|
||||
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
|
||||
mempal_dump_bad_input "$INPUT" "stop"
|
||||
# Fail-open: don't block the host on a parse error.
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"loop_count=$MEMPAL_LOOP_COUNT status=${MEMPAL_STATUS:-?} workspace=$MEMPAL_WORKSPACE"
|
||||
|
||||
# ── Loop-prevention ────────────────────────────────────────────────
|
||||
#
|
||||
# Cursor's loop_count indicates how many times THIS stop hook has
|
||||
# already triggered an automatic followup for this conversation
|
||||
# (starts at 0). If it is > 0, our own previous followup is currently
|
||||
# being consumed by the agent — let it finish without re-firing.
|
||||
if [ "$MEMPAL_LOOP_COUNT" -gt 0 ] 2>/dev/null; then
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" "loop_count>0; letting agent stop"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WING="$(mempal_infer_wing "$MEMPAL_WORKSPACE")"
|
||||
|
||||
# Build the followup message once; both the pending-marker branch and
|
||||
# the threshold branch use it. Constructed via Python -c (rather than
|
||||
# a heredoc) so we can pass the inferred wing as argv[1] and so the
|
||||
# JSON encoding is correct even for wings whose name would otherwise
|
||||
# need shell quoting.
|
||||
_mempal_build_followup() {
|
||||
"$MEMPAL_PYTHON_BIN" -c '
|
||||
import json, sys
|
||||
wing = sys.argv[1] if len(sys.argv) > 1 else "cursor_session"
|
||||
msg = (
|
||||
"MemPalace save checkpoint. "
|
||||
"(1) Call mempalace_check_duplicate on the key topics, decisions, "
|
||||
"and verbatim quotes from this session. "
|
||||
"(2) For each non-duplicate, call mempalace_add_drawer (wing="
|
||||
+ wing + ", room=<short topic>, content=verbatim quote). "
|
||||
"(3) Call mempalace_diary_write (agent_name=cursor-ide, wing="
|
||||
+ wing + ", entry=AAAK-format summary). "
|
||||
"Then stop."
|
||||
)
|
||||
print(json.dumps({"followup_message": msg}))
|
||||
' "$WING"
|
||||
}
|
||||
|
||||
# ── Pending-save marker from preCompact ───────────────────────────
|
||||
#
|
||||
# preCompact cannot itself emit a followup_message (Cursor docs:
|
||||
# preCompact is observational-only, output supports only user_message),
|
||||
# so it drops a marker file and we consume it here. Forces a save
|
||||
# nudge regardless of the counter.
|
||||
if mempal_consume_pending "$MEMPAL_CONV_ID"; then
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"consumed pending-save marker (post-compaction)"
|
||||
if mempal_followup_silenced; then
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"followup silenced (MEMPAL_CURSOR_SILENT/MEMPAL_VERBOSE); emitting {}"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
_mempal_build_followup
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Normal counter path ───────────────────────────────────────────
|
||||
COUNTER_FILE="$(_mempal_counter_path "$MEMPAL_CONV_ID")"
|
||||
CURRENT="$(mempal_read_counter "$COUNTER_FILE")"
|
||||
NEXT=$((CURRENT + 1))
|
||||
mempal_write_counter_atomic "$COUNTER_FILE" "$NEXT" || {
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"WARN: counter write failed for $COUNTER_FILE; passing through"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"counter $CURRENT -> $NEXT (interval=$SAVE_INTERVAL)"
|
||||
|
||||
# Trigger when we hit a multiple of SAVE_INTERVAL. Modulo arithmetic
|
||||
# keeps the counter monotonically growing (no reset) so the log file
|
||||
# is greppable for total turns across a conversation.
|
||||
if [ "$((NEXT % SAVE_INTERVAL))" -ne 0 ]; then
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" "TRIGGERING SAVE at counter=$NEXT"
|
||||
|
||||
# ── Background mine (best effort) ─────────────────────────────────
|
||||
#
|
||||
# Two independent targets — both run if both are set:
|
||||
# 1. transcript_path → its parent directory, --mode convos
|
||||
# 2. MEMPAL_DIR (user-configured project) → --mode projects
|
||||
#
|
||||
# IMPORTANT (Cursor caveat): the --mode convos mine is BEST-EFFORT for
|
||||
# Cursor. Cursor's transcript format is undocumented and
|
||||
# mempalace/normalize.py has no Cursor parser, so this call does not
|
||||
# yet produce clean verbatim conversation drawers — at best it ingests
|
||||
# raw bytes. The verbatim-capture guarantee for Cursor is carried by
|
||||
# the followup_message below, which drives the agent to file its own
|
||||
# in-context verbatim quotes. The --mode projects target (MEMPAL_DIR)
|
||||
# is unaffected — normalize.py reads ordinary project files fine.
|
||||
#
|
||||
# Both run with stdout/stderr appended to the cursor log and are
|
||||
# backgrounded so a slow mine cannot push the hook past its
|
||||
# Cursor-configured timeout. `command -v mempalace` gates so a user
|
||||
# without the CLI on PATH (e.g. a fresh GUI-launched session) does
|
||||
# not see a noisy error.
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
if mempal_is_valid_transcript "$MEMPAL_TRANSCRIPT" \
|
||||
&& [ -f "$MEMPAL_TRANSCRIPT" ]; then
|
||||
( mempalace mine "$(dirname "$MEMPAL_TRANSCRIPT")" --mode convos \
|
||||
>> "$MEMPAL_CURSOR_LOG" 2>&1 ) &
|
||||
elif [ -n "$MEMPAL_TRANSCRIPT" ]; then
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"skipping invalid transcript path: $MEMPAL_TRANSCRIPT"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
( mempalace mine "$MEMPAL_DIR" --mode projects \
|
||||
>> "$MEMPAL_CURSOR_LOG" 2>&1 ) &
|
||||
fi
|
||||
else
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"mempalace CLI not on PATH; skipping background mine"
|
||||
fi
|
||||
|
||||
# The followup is the load-bearing verbatim path for Cursor (see header),
|
||||
# so it fires by default. Honour the opt-out for users who want silence.
|
||||
if mempal_followup_silenced; then
|
||||
mempal_log "stop" "$MEMPAL_CONV_ID" \
|
||||
"followup silenced (MEMPAL_CURSOR_SILENT/MEMPAL_VERBOSE); background mine only"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
_mempal_build_followup
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
# MEMPALACE CURSOR WAKE HOOK — Session-start memory recall
|
||||
#
|
||||
# Cursor "sessionStart" hook. This is a Cursor-only capability —
|
||||
# Claude Code's third-party-hooks compatibility layer does not have
|
||||
# an equivalent event with the same "inject context into the agent's
|
||||
# initial system message" semantics.
|
||||
#
|
||||
# Behaviour:
|
||||
# 1. Parse Cursor's sessionStart payload (conversation_id /
|
||||
# session_id, is_background_agent, composer_mode, plus the
|
||||
# common workspace_roots field).
|
||||
# 2. Infer the wing from basename(workspace_roots[0]).
|
||||
# 3. Return {"additional_context": "..."} instructing the agent to
|
||||
# scope its memory recall by calling mempalace_search +
|
||||
# mempalace_diary_read with wing=<inferred>.
|
||||
#
|
||||
# sessionStart is documented as fire-and-forget — Cursor does not
|
||||
# enforce a blocking response and does not consume "continue" /
|
||||
# "user_message" — but "additional_context" is honoured and added to
|
||||
# the conversation's initial system context. Verified in
|
||||
# cursor.com/docs/hooks.md (fetched 2026-05-27).
|
||||
#
|
||||
# === INSTALL ===
|
||||
#
|
||||
# Add to ~/.cursor/hooks.json (or .cursor/hooks.json for project
|
||||
# scope) under "sessionStart":
|
||||
#
|
||||
# {
|
||||
# "version": 1,
|
||||
# "hooks": {
|
||||
# "sessionStart": [
|
||||
# { "command": "/absolute/path/to/mempal_wake_hook_cursor.sh" }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
|
||||
_mempal_self="${BASH_SOURCE[0]:-$0}"
|
||||
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
|
||||
# shellcheck source=lib/common.sh
|
||||
. "$_mempal_dir/lib/common.sh"
|
||||
|
||||
if mempal_is_disabled; then
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
INPUT="$(cat)"
|
||||
mempal_parse_stdin "$INPUT"
|
||||
|
||||
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
|
||||
mempal_dump_bad_input "$INPUT" "sessionStart"
|
||||
mempal_emit '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WING="$(mempal_infer_wing "$MEMPAL_WORKSPACE")"
|
||||
|
||||
mempal_log "sessionStart" "$MEMPAL_CONV_ID" \
|
||||
"workspace=$MEMPAL_WORKSPACE wing=$WING"
|
||||
|
||||
# Emit the additional_context payload via Python -c (rather than a
|
||||
# heredoc) so the JSON encoding survives wings whose name contains
|
||||
# characters that would otherwise need shell escaping, and so the
|
||||
# inferred wing arrives as an argv positional. The MCP tool names
|
||||
# referenced here are verified against mempalace/mcp_server.py:
|
||||
# mempalace_search and mempalace_diary_read both exist and accept
|
||||
# the wing parameter.
|
||||
"$MEMPAL_PYTHON_BIN" -c '
|
||||
import json, sys
|
||||
wing = sys.argv[1] if len(sys.argv) > 1 else "cursor_session"
|
||||
ctx = (
|
||||
"MemPalace wake-up. The Cursor workspace maps to wing=" + wing + ". "
|
||||
"Before answering anything that touches past work in this "
|
||||
"project, call mempalace_search (wing=" + wing + ", "
|
||||
"query=<relevant keywords>) and mempalace_diary_read "
|
||||
"(agent_name=cursor-ide, wing=" + wing + ", last_n=10). "
|
||||
"Use what you find verbatim where it answers the question; "
|
||||
"never summarise the user'"'"'s own words."
|
||||
)
|
||||
print(json.dumps({"additional_context": ctx}))
|
||||
' "$WING"
|
||||
@@ -119,16 +119,8 @@ INPUT=$(cat)
|
||||
# backslashes are not mangled by echo flag parsing.
|
||||
_mempal_parsed=$(
|
||||
umask 077
|
||||
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
sid = data.get('session_id', '')
|
||||
tp = data.get('transcript_path', '')
|
||||
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
|
||||
print('__MEMPAL_PARSE_OK__')
|
||||
print(safe(sid))
|
||||
print(safe(tp))
|
||||
" 2>"$STATE_DIR/last_python_err.log"
|
||||
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell parse-precompact \
|
||||
2>"$STATE_DIR/last_python_err.log"
|
||||
)
|
||||
# Drop the empty file on success; chmod 600 on failure to mirror
|
||||
# last_input.log's privacy contract.
|
||||
@@ -193,7 +185,7 @@ if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; the
|
||||
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
|
||||
>> "$STATE_DIR/hook.log" 2>&1
|
||||
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" \
|
||||
echo "[$(date '+%H:%M:%S')] Skipping missing or invalid transcript path after normalization: $TRANSCRIPT_PATH" \
|
||||
>> "$STATE_DIR/hook.log"
|
||||
fi
|
||||
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
|
||||
|
||||
@@ -149,21 +149,8 @@ INPUT=$(cat)
|
||||
# ``printf '%s'`` removes the class of bug entirely.
|
||||
_mempal_parsed=$(
|
||||
umask 077
|
||||
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
|
||||
import sys, json, re
|
||||
data = json.load(sys.stdin)
|
||||
sid = data.get('session_id', '')
|
||||
sha_raw = data.get('stop_hook_active', False)
|
||||
tp = data.get('transcript_path', '')
|
||||
# Shell-safe output: only allow alphanumeric, underscore, hyphen, slash, dot, tilde
|
||||
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('__MEMPAL_PARSE_OK__')
|
||||
print(safe(sid))
|
||||
print(sha)
|
||||
print(safe(tp))
|
||||
" 2>"$STATE_DIR/last_python_err.log"
|
||||
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell parse-stop \
|
||||
2>"$STATE_DIR/last_python_err.log"
|
||||
)
|
||||
# The 2> redirect creates the file even when stderr is empty (success).
|
||||
# Remove the empty file so the state directory stays clean on the happy
|
||||
@@ -239,24 +226,10 @@ fi
|
||||
# Count human messages in the JSONL transcript
|
||||
# SECURITY: Pass transcript path as sys.argv to avoid shell injection via crafted paths
|
||||
if [ -f "$TRANSCRIPT_PATH" ]; then
|
||||
EXCHANGE_COUNT=$("$MEMPAL_PYTHON_BIN" - "$TRANSCRIPT_PATH" <<'PYEOF'
|
||||
import json, sys
|
||||
count = 0
|
||||
with open(sys.argv[1]) as f:
|
||||
for line in f:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
msg = entry.get('message', {})
|
||||
if isinstance(msg, dict) and msg.get('role') == 'user':
|
||||
content = msg.get('content', '')
|
||||
if isinstance(content, str) and '<command-message>' in content:
|
||||
continue
|
||||
count += 1
|
||||
except:
|
||||
pass
|
||||
print(count)
|
||||
PYEOF
|
||||
2>/dev/null)
|
||||
EXCHANGE_COUNT=$("$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell count-human-messages "$TRANSCRIPT_PATH" 2>/dev/null)
|
||||
elif [ -n "$TRANSCRIPT_PATH" ]; then
|
||||
echo "[$(date '+%H:%M:%S')] WARN: transcript_path not found after normalization: $TRANSCRIPT_PATH" >> "$STATE_DIR/hook.log"
|
||||
EXCHANGE_COUNT=0
|
||||
else
|
||||
EXCHANGE_COUNT=0
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# MemPalace Recall Protocol
|
||||
|
||||
The canonical "search before answering" protocol shared across every
|
||||
MemPalace integration (Cursor, Antigravity, Claude Code, Codex,
|
||||
OpenClaw). This file is the single source of truth — skills and rules
|
||||
should link here rather than restating the protocol, so the rule never
|
||||
drifts from the skill.
|
||||
|
||||
The protocol exists to honour MemPalace's foundational promise:
|
||||
**100% recall, verbatim, never guess.** When the palace might hold the
|
||||
answer, the agent must read the palace before answering from model
|
||||
memory.
|
||||
|
||||
## When to recall
|
||||
|
||||
Search the palace **before answering** whenever the user asks about
|
||||
anything that may already be filed:
|
||||
|
||||
- Past work, prior decisions, or "what did we do / decide / try?"
|
||||
- A person, project, or entity ("who is …", "what is …")
|
||||
- Something that happened in an earlier session ("remember when …",
|
||||
"last time …", "the thing we discussed")
|
||||
- A preference, fact, or relationship that could have changed over time
|
||||
|
||||
If the question is pure greenfield work with no memory relevance (e.g.
|
||||
"rename this variable", "fix this typo"), do not search — recall is
|
||||
question-driven, not reflexive.
|
||||
|
||||
## The protocol
|
||||
|
||||
1. **On wake-up** (if a session-start hook injected context, honour its wing scoping / `additional_context`): scope recall to the wing inferred from the workspace, then continue.
|
||||
2. **Before responding** about people, projects, past events, or prior
|
||||
decisions: call `mempalace_search` first. For relational or temporal
|
||||
facts ("who reported to whom in March", "what was true then"), call
|
||||
`mempalace_kg_query` instead or as well.
|
||||
3. **If unsure** about a fact (name, age, relationship, preference): say
|
||||
"let me check the palace" and query. Wrong is worse than slow.
|
||||
4. **Return verbatim.** Quote the drawer's exact stored words. Never
|
||||
summarize, paraphrase, or lossy-compress what the palace returns —
|
||||
that is the whole point of the system.
|
||||
5. **After a substantive session**, record continuity with
|
||||
`mempalace_diary_write` (background hooks may already do this — do not
|
||||
double-file).
|
||||
6. **When a fact changes**, call `mempalace_kg_invalidate` on the old
|
||||
fact, then `mempalace_kg_add` for the new one.
|
||||
|
||||
## Tool selection
|
||||
|
||||
| You need | Tool |
|
||||
|---|---|
|
||||
| Find any memory by meaning | `mempalace_search` (start here) |
|
||||
| Relational / time-bound facts about an entity | `mempalace_kg_query` |
|
||||
| The chronological story of an entity | `mempalace_kg_timeline` |
|
||||
| Recent session continuity | `mempalace_diary_read` |
|
||||
| Which wings / rooms exist (when scope unknown) | `mempalace_list_wings`, `mempalace_list_rooms` |
|
||||
| Record this session | `mempalace_diary_write` |
|
||||
|
||||
`mempalace_search` takes a short natural-language `query` (keywords or a
|
||||
question — not a system prompt or pasted conversation) plus optional
|
||||
`wing` / `room` filters and `limit` (default 5).
|
||||
|
||||
## Unhappy paths
|
||||
|
||||
- **Empty results.** Say the palace has nothing on this; do not invent an
|
||||
answer to fill the gap. Offer to widen the search (drop the wing
|
||||
filter) or to file the new information.
|
||||
- **MCP unavailable / tool error.** Surface the error plainly and suggest
|
||||
the user verify the server (`mempalace status`, or re-run install).
|
||||
Do not silently fall back to guessing from model memory.
|
||||
- **Stale or conflicting facts.** Prefer the knowledge graph's
|
||||
time-valid answer; if a fact has changed, invalidate the old one and
|
||||
add the new one rather than overwriting context silently.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Answering about past work, people, or decisions from model memory when
|
||||
the palace might know — search first.
|
||||
- Paraphrasing or summarizing stored content instead of quoting it
|
||||
verbatim.
|
||||
- Searching reflexively on every turn, including pure greenfield coding
|
||||
with no memory relevance.
|
||||
- Pasting the full conversation or a system prompt into the `query`
|
||||
argument — keep queries short and keyword-driven.
|
||||
|
||||
## See also
|
||||
|
||||
- [`integrations/openclaw/SKILL.md`](../openclaw/SKILL.md) — the original
|
||||
full-protocol skill this is distilled from.
|
||||
- MemPalace design principles (verbatim, local-first, never summarize):
|
||||
<https://github.com/MemPalace/mempalace>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ The Python package that powers MemPalace. All modules, all logic.
|
||||
| `dialect.py` | AAAK compression — entity codes, emotion markers, 30x lossless ratio |
|
||||
| `knowledge_graph.py` | Temporal entity-relationship graph — SQLite, time-filtered queries, fact invalidation |
|
||||
| `palace_graph.py` | Room-based navigation graph — BFS traversal, tunnel detection across wings |
|
||||
| `mcp_server.py` | MCP server — 19 tools, AAAK auto-teach, Palace Protocol, agent diary |
|
||||
| `mcp_server.py` | MCP server — 33 tools, AAAK auto-teach, Palace Protocol, agent diary |
|
||||
| `onboarding.py` | Guided first-run setup — asks about people/projects, generates AAAK bootstrap + wing config |
|
||||
| `entity_registry.py` | Entity code registry — maps names to AAAK codes, handles ambiguous names |
|
||||
| `entity_detector.py` | Auto-detect people and projects from file content |
|
||||
|
||||
@@ -17,6 +17,7 @@ Public surface:
|
||||
from .base import (
|
||||
BackendClosedError,
|
||||
BackendError,
|
||||
BackendMismatchError,
|
||||
BaseBackend,
|
||||
BaseCollection,
|
||||
CollectionNotInitializedError,
|
||||
@@ -24,14 +25,24 @@ from .base import (
|
||||
EmbedderIdentityMismatchError,
|
||||
GetResult,
|
||||
HealthStatus,
|
||||
LexicalHit,
|
||||
LexicalResult,
|
||||
MaintenanceResult,
|
||||
PalaceNotFoundError,
|
||||
PalaceRef,
|
||||
QueryResult,
|
||||
UnsupportedCapabilityError,
|
||||
UnsupportedFilterError,
|
||||
UnsupportedMaintenanceKindError,
|
||||
)
|
||||
from .chroma import ChromaBackend, ChromaCollection
|
||||
from .pgvector import PgVectorBackend, PgVectorCollection
|
||||
from .qdrant import QdrantBackend, QdrantCollection
|
||||
from .sqlite_exact import SQLiteExactBackend, SQLiteExactCollection
|
||||
from .registry import (
|
||||
available_backends,
|
||||
detect_backend_for_path,
|
||||
detect_backends_for_path,
|
||||
get_backend,
|
||||
get_backend_class,
|
||||
register,
|
||||
@@ -43,6 +54,7 @@ from .registry import (
|
||||
__all__ = [
|
||||
"BackendClosedError",
|
||||
"BackendError",
|
||||
"BackendMismatchError",
|
||||
"BaseBackend",
|
||||
"BaseCollection",
|
||||
"ChromaBackend",
|
||||
@@ -52,11 +64,24 @@ __all__ = [
|
||||
"EmbedderIdentityMismatchError",
|
||||
"GetResult",
|
||||
"HealthStatus",
|
||||
"LexicalHit",
|
||||
"LexicalResult",
|
||||
"MaintenanceResult",
|
||||
"PalaceNotFoundError",
|
||||
"PalaceRef",
|
||||
"PgVectorBackend",
|
||||
"PgVectorCollection",
|
||||
"QdrantBackend",
|
||||
"QdrantCollection",
|
||||
"QueryResult",
|
||||
"SQLiteExactBackend",
|
||||
"SQLiteExactCollection",
|
||||
"UnsupportedCapabilityError",
|
||||
"UnsupportedFilterError",
|
||||
"UnsupportedMaintenanceKindError",
|
||||
"available_backends",
|
||||
"detect_backend_for_path",
|
||||
"detect_backends_for_path",
|
||||
"get_backend",
|
||||
"get_backend_class",
|
||||
"register",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Shared embedder-identity sidecar (RFC 001).
|
||||
|
||||
A small JSON file in the palace directory, keyed by collection name, recording
|
||||
the embedder identity (``model_name`` / ``dimension``). It is deliberately
|
||||
*separate* from a backend's mismatch marker: a marker's presence signals
|
||||
"palace initialized" (reads raise ``CollectionNotInitializedError`` when the
|
||||
marker exists but the store doesn't), so recording identity at first empty open
|
||||
must not create one. The sidecar is unguarded, so a brand-new palace can record
|
||||
identity immediately — the same approach the chroma backend uses.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
EMBEDDER_SIDECAR_FILENAME = "mempalace_embedder.json"
|
||||
|
||||
|
||||
def read_embedder_sidecar(path: Optional[str], collection_name: Optional[str]):
|
||||
"""Return the recorded :class:`EmbedderIdentity` for ``collection_name``, or None.
|
||||
|
||||
Robust to a missing, unreadable, or malformed (non-dict) sidecar — any of
|
||||
those degrade to ``None`` (the ``unknown`` state) rather than raising.
|
||||
"""
|
||||
from .base import EmbedderIdentity
|
||||
|
||||
if not path or not collection_name or not os.path.isfile(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
entry = data.get(collection_name)
|
||||
if not isinstance(entry, dict) or not entry.get("model_name"):
|
||||
return None
|
||||
return EmbedderIdentity(
|
||||
model_name=str(entry["model_name"]),
|
||||
dimension=int(entry.get("dimension") or 0),
|
||||
)
|
||||
|
||||
|
||||
def write_embedder_sidecar(path: Optional[str], collection_name: Optional[str], identity) -> None:
|
||||
"""Record ``identity`` for ``collection_name`` in the sidecar, creating it if needed.
|
||||
|
||||
No-ops for a missing path, missing collection name, or a nameless identity.
|
||||
Preserves other collections' entries; never raises on I/O failure.
|
||||
"""
|
||||
if not path or not collection_name or not identity or not getattr(identity, "model_name", ""):
|
||||
return
|
||||
data: dict = {}
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
data = loaded
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
data[collection_name] = {
|
||||
"model_name": str(identity.model_name),
|
||||
"dimension": int(identity.dimension or 0),
|
||||
}
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.chmod(path, 0o600)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
+269
-2
@@ -14,8 +14,8 @@ conformance suite land in follow-up PRs.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from typing import ClassVar, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -58,6 +58,23 @@ class UnsupportedFilterError(BackendError):
|
||||
"""
|
||||
|
||||
|
||||
class UnsupportedCapabilityError(BackendError):
|
||||
"""Raised when a backend does not implement an optional capability."""
|
||||
|
||||
|
||||
class UnsupportedMaintenanceKindError(BackendError):
|
||||
"""Raised when ``run_maintenance(kind)`` is called with an unadvertised kind.
|
||||
|
||||
A backend MUST advertise a kind in ``maintenance_kinds`` before it accepts
|
||||
it (RFC 001). Advertising a kind it does not implement is a conformance
|
||||
failure; a kind it has no analogue for MUST be omitted, not no-op'd.
|
||||
"""
|
||||
|
||||
|
||||
class BackendMismatchError(BackendError):
|
||||
"""Raised when a selected backend does not match existing palace artifacts."""
|
||||
|
||||
|
||||
class DimensionMismatchError(BackendError):
|
||||
"""Raised when the embedding dimension on write does not match the collection."""
|
||||
|
||||
@@ -66,6 +83,15 @@ class EmbedderIdentityMismatchError(BackendError):
|
||||
"""Raised when the stored embedder model name differs from the current one."""
|
||||
|
||||
|
||||
class EmbedderIdentityUnknownWarning(UserWarning):
|
||||
"""Emitted on first open of a collection with no recorded embedder identity.
|
||||
|
||||
Legacy palaces created before identity tracking carry no model name. Per
|
||||
RFC 001 the right behavior is warn-not-fail: the identity is recorded on
|
||||
the next write and subsequent opens become strict.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Value objects
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -78,6 +104,29 @@ class PalaceRef:
|
||||
``id`` is always present and is the key backends use to cache handles.
|
||||
``local_path`` is populated for filesystem-rooted palaces.
|
||||
``namespace`` is used by server-mode backends for tenant / prefix routing.
|
||||
|
||||
Isolation contract (RFC 001 §2.1, conformance: ``tests/test_backend_conformance.py``)
|
||||
-----------------------------------------------------------------------------------
|
||||
``id`` is the *required* isolation key. Within a single backend instance:
|
||||
|
||||
A record written for one ``PalaceRef.id`` MUST NOT be returned,
|
||||
modified, or deleted by an operation issued for a different
|
||||
``PalaceRef.id``. Cross-palace access is a spec violation.
|
||||
|
||||
``namespace`` is *additional* partitioning, honored only by backends that
|
||||
advertise the ``supports_namespace_isolation`` capability. For those
|
||||
backends the same guarantee extends to namespaces:
|
||||
|
||||
A record written under one ``namespace`` MUST NOT be returned,
|
||||
modified, or deleted by an operation issued under a different
|
||||
``namespace`` within the same backend instance. Cross-namespace
|
||||
access is a spec violation.
|
||||
|
||||
Backends that do not advertise ``supports_namespace_isolation`` (e.g.
|
||||
``sqlite_exact``, whose isolation is the on-disk path alone) MAY ignore
|
||||
``namespace`` entirely; callers MUST NOT rely on it for tenant isolation
|
||||
on such backends. Any conforming backend can self-check both guarantees by
|
||||
running the shared assertions in ``tests/_backend_conformance.py``.
|
||||
"""
|
||||
|
||||
id: str
|
||||
@@ -85,6 +134,114 @@ class PalaceRef:
|
||||
namespace: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmbedderIdentity:
|
||||
"""Identity of the embedder that produced a collection's vectors (RFC 001).
|
||||
|
||||
``model_name`` is the stable identity persisted alongside a collection and
|
||||
checked on subsequent opens. ``dimension`` is the vector width. A
|
||||
``dimension`` of ``0`` means *unknown / not probed* — comparisons treat it
|
||||
as "no dimension signal" rather than a real zero-width vector, so a cheap
|
||||
read-path check can compare model names without loading the model.
|
||||
"""
|
||||
|
||||
model_name: str
|
||||
dimension: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MaintenanceResult:
|
||||
"""Observable outcome of ``run_maintenance(kind)`` (RFC 001).
|
||||
|
||||
Maintenance is *not* fire-and-forget: a backend MUST serialize concurrent
|
||||
same-kind runs and report the outcome so a caller can learn it must not
|
||||
re-trigger. ``status`` is one of:
|
||||
|
||||
* ``"ran"`` — this call performed the maintenance.
|
||||
* ``"already_running"`` — another caller holds the work; this call did
|
||||
nothing and the caller MUST NOT re-trigger (the production index-build
|
||||
wedge: concurrent writers each issuing the build stacked exclusive locks).
|
||||
* ``"noop"`` — nothing needed doing (e.g. the index already exists).
|
||||
|
||||
``stats`` is free-form per kind (rows analyzed, bytes reclaimed, index
|
||||
build time) for benchmark/operator reporting.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
status: str
|
||||
stats: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Embedder(Protocol):
|
||||
"""Minimal embedder contract (RFC 001, normative for identity checking).
|
||||
|
||||
The fuller embedder RFC (batching/async/pooling) is additive; identity
|
||||
enforcement depends only on these three members.
|
||||
"""
|
||||
|
||||
model_name: str
|
||||
dimension: int
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
||||
|
||||
|
||||
def check_embedder_identity(
|
||||
stored: Optional[EmbedderIdentity],
|
||||
current: Optional[EmbedderIdentity],
|
||||
*,
|
||||
force_model_swap: bool = False,
|
||||
) -> str:
|
||||
"""Three-state embedder-identity check (RFC 001).
|
||||
|
||||
Returns the resolved state and raises on a hard, unforced conflict:
|
||||
|
||||
* ``"unknown"`` — no identity recorded yet (legacy collection), or the
|
||||
current embedder is nameless. The caller warns and records on write.
|
||||
* ``"known_match"`` — stored name (and dimension, when both known) equal
|
||||
the current embedder. Proceed normally.
|
||||
* ``"known_mismatch"`` — names or dimensions differ. Without
|
||||
``force_model_swap`` this raises (:class:`EmbedderIdentityMismatchError`
|
||||
for a model swap, :class:`DimensionMismatchError` for a width change,
|
||||
which is checked first because mismatched vectors are physically
|
||||
unusable). With ``force_model_swap`` it returns the state so the caller
|
||||
can re-record the identity and log the swap.
|
||||
|
||||
A ``dimension`` of ``0`` on either side means "unknown" and is skipped, so
|
||||
a model-name-only check (cheap read path) still works.
|
||||
"""
|
||||
if current is None or not current.model_name:
|
||||
return "unknown"
|
||||
if stored is None:
|
||||
return "unknown"
|
||||
|
||||
dim_conflict = bool(stored.dimension and current.dimension) and (
|
||||
stored.dimension != current.dimension
|
||||
)
|
||||
name_conflict = stored.model_name != current.model_name
|
||||
|
||||
if not dim_conflict and not name_conflict:
|
||||
return "known_match"
|
||||
|
||||
if force_model_swap:
|
||||
return "known_mismatch"
|
||||
|
||||
if dim_conflict:
|
||||
raise DimensionMismatchError(
|
||||
f"collection was built with a {stored.dimension}-dim embedder "
|
||||
f"({stored.model_name!r}) but the current embedder is "
|
||||
f"{current.dimension}-dim ({current.model_name!r}); the stored "
|
||||
"vectors are incompatible. Re-embed the palace to switch models."
|
||||
)
|
||||
raise EmbedderIdentityMismatchError(
|
||||
f"collection was built with embedder {stored.model_name!r} but the "
|
||||
f"current embedder is {current.model_name!r}. Searching across a model "
|
||||
"swap silently degrades recall. Re-embed the palace, or run "
|
||||
"`mempalace palace set-embedder --model <name> --force` to record the "
|
||||
"new identity if you know the vectors are compatible."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthStatus:
|
||||
ok: bool
|
||||
@@ -177,6 +334,23 @@ class GetResult(_DictCompatMixin):
|
||||
return cls(ids=[], documents=[], metadatas=[], embeddings=None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LexicalHit:
|
||||
"""One hit from backend lexical candidate search."""
|
||||
|
||||
id: str
|
||||
document: str
|
||||
metadata: dict
|
||||
score: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LexicalResult:
|
||||
"""Typed return from ``BaseCollection.lexical_search``."""
|
||||
|
||||
hits: list[LexicalHit]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection contract
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -253,6 +427,77 @@ class BaseCollection(ABC):
|
||||
def health(self) -> HealthStatus:
|
||||
return HealthStatus.healthy()
|
||||
|
||||
@property
|
||||
def distance_metric(self) -> str:
|
||||
"""The space this collection's ``distances`` are reported in.
|
||||
|
||||
Defaults to the owning backend's declared metric (cosine for all
|
||||
in-tree backends). Collections that can vary per-collection — e.g. a
|
||||
legacy Chroma palace built without ``hnsw:space=cosine`` — override
|
||||
this to report their actual space so core ranking converts correctly.
|
||||
"""
|
||||
return "cosine"
|
||||
|
||||
def get_stored_embedder_identity(self) -> Optional[EmbedderIdentity]:
|
||||
"""Return the embedder identity recorded for this collection, if any.
|
||||
|
||||
Returns ``None`` when nothing is recorded — a legacy collection, or a
|
||||
backend that does not yet persist identity. Core treats ``None`` as the
|
||||
``unknown`` state (warn, do not fail). Backends override this and
|
||||
:meth:`set_embedder_identity` against their own metadata store.
|
||||
"""
|
||||
return None
|
||||
|
||||
def set_embedder_identity(self, identity: EmbedderIdentity) -> None:
|
||||
"""Persist this collection's embedder identity. Default: no-op.
|
||||
|
||||
A backend without an identity slot inherits the no-op default and so
|
||||
stays permanently ``unknown`` (safe — it simply never enforces). The
|
||||
enforcement choke point calls this when recording on first write or
|
||||
on an explicit, forced model swap.
|
||||
"""
|
||||
return None
|
||||
|
||||
def effective_embedder_identity(self) -> Optional[EmbedderIdentity]:
|
||||
"""The identity of the embedder this collection actually uses.
|
||||
|
||||
For ``server_embedder`` backends that ignore the injected embedder,
|
||||
this reports the server-side embedder so the same identity rules apply
|
||||
(RFC 001). Defaults to ``None`` — the collection is embedded by the
|
||||
injected/core embedder, and the caller supplies the current identity.
|
||||
"""
|
||||
return None
|
||||
|
||||
def maintenance_state(self) -> dict:
|
||||
"""Return a structured snapshot of this collection's maintenance state.
|
||||
|
||||
Free-form per backend (e.g. row count, whether a vector index exists,
|
||||
last-analyze age). Used by benchmark harnesses to record state
|
||||
alongside each latency/recall measurement so an un-analyzed store is
|
||||
not compared against a settled one (RFC 001). Defaults to empty.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def run_maintenance(self, kind: str) -> "MaintenanceResult":
|
||||
"""Run a maintenance ``kind`` and return an observable result (RFC 001).
|
||||
|
||||
Backends advertise supported kinds in ``BaseBackend.maintenance_kinds``
|
||||
and override this. The default supports nothing, so every kind raises
|
||||
:class:`UnsupportedMaintenanceKindError`. Implementations MUST serialize
|
||||
concurrent same-kind runs and report ``already_running`` rather than
|
||||
stacking the work.
|
||||
"""
|
||||
raise UnsupportedMaintenanceKindError(f"backend does not support maintenance kind {kind!r}")
|
||||
|
||||
def lexical_search(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
n_results: int = 10,
|
||||
where: Optional[dict] = None,
|
||||
) -> LexicalResult:
|
||||
raise UnsupportedCapabilityError("backend does not support lexical_search")
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
@@ -311,11 +556,33 @@ class BaseBackend(ABC):
|
||||
Instances are lightweight on construction — no I/O, no network. All
|
||||
connection work is deferred to ``get_collection``. Instances are thread-
|
||||
safe for concurrent ``get_collection`` calls across different palaces.
|
||||
|
||||
Every backend MUST satisfy the per-``PalaceRef.id`` isolation guarantee in
|
||||
:class:`PalaceRef`. Backends that additionally isolate by
|
||||
``PalaceRef.namespace`` (multi-tenant / hosted deployments) MUST advertise
|
||||
the ``supports_namespace_isolation`` capability token; doing so is a
|
||||
promise to satisfy the cross-namespace guarantee and to pass the namespace
|
||||
arm of the conformance suite. Backends without the token MAY ignore
|
||||
``namespace``.
|
||||
"""
|
||||
|
||||
name: ClassVar[str]
|
||||
spec_version: ClassVar[str] = "1.0"
|
||||
capabilities: ClassVar[frozenset[str]] = frozenset()
|
||||
#: The space ``query()`` reports ``distances`` in (RFC 001 §2.1).
|
||||
#: One of ``"cosine"`` | ``"l2"`` | ``"ip"``. The contract for the
|
||||
#: ``distances`` field is *lower = closer* regardless of metric; core
|
||||
#: search converts distance→similarity off this declaration rather than
|
||||
#: assuming cosine. All in-tree backends are cosine today.
|
||||
distance_metric: ClassVar[str] = "cosine"
|
||||
#: Maintenance kinds this backend implements (RFC 001). Reserved names:
|
||||
#: ``"analyze"`` (refresh planner/query statistics), ``"compact"`` (reclaim
|
||||
#: space, rewrite storage), ``"reindex"`` (build/rebuild secondary indexes).
|
||||
#: A backend with no analogue for a kind MUST omit it rather than declare a
|
||||
#: no-op, so a benchmark harness can trust the set. Backends MAY add their
|
||||
#: own kinds. ``run_maintenance`` raises ``UnsupportedMaintenanceKindError``
|
||||
#: for anything not listed here.
|
||||
maintenance_kinds: ClassVar[frozenset[str]] = frozenset()
|
||||
|
||||
@abstractmethod
|
||||
def get_collection(
|
||||
|
||||
+689
-88
@@ -2,10 +2,14 @@
|
||||
|
||||
import contextlib
|
||||
import datetime as _dt
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import pickle
|
||||
import re
|
||||
import sqlite3
|
||||
from collections import defaultdict
|
||||
from numbers import Integral
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -13,12 +17,15 @@ from typing import Any, Optional
|
||||
import chromadb
|
||||
from chromadb.errors import NotFoundError as _ChromaNotFoundError
|
||||
|
||||
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
|
||||
from .base import (
|
||||
BaseBackend,
|
||||
BaseCollection,
|
||||
CollectionNotInitializedError,
|
||||
GetResult,
|
||||
HealthStatus,
|
||||
LexicalHit,
|
||||
LexicalResult,
|
||||
PalaceNotFoundError,
|
||||
PalaceRef,
|
||||
QueryResult,
|
||||
@@ -32,6 +39,7 @@ logger = logging.getLogger(__name__)
|
||||
_REQUIRED_OPERATORS = frozenset({"$eq", "$ne", "$in", "$nin", "$and", "$or", "$contains"})
|
||||
_OPTIONAL_OPERATORS = frozenset({"$gt", "$gte", "$lt", "$lte"})
|
||||
_SUPPORTED_OPERATORS = _REQUIRED_OPERATORS | _OPTIONAL_OPERATORS
|
||||
_TOKEN_RE = re.compile(r"\w{2,}", re.UNICODE)
|
||||
|
||||
# A healthy HNSW payload should keep link_lists.bin proportional to
|
||||
# data_level0.bin. When link_lists.bin grows orders of magnitude larger than
|
||||
@@ -104,36 +112,38 @@ def _hnsw_payload_appears_sane(seg_dir: str) -> bool:
|
||||
return ratio is None or ratio <= _HNSW_LINK_TO_DATA_MAX_RATIO
|
||||
|
||||
|
||||
# HNSW tuning to prevent link_lists.bin bloat on large mines (#344).
|
||||
# HNSW batch/sync thresholds applied at collection creation.
|
||||
#
|
||||
# With default params (batch_size=100, sync_threshold=1000, initial capacity
|
||||
# 1000), inserting tens of thousands of drawers triggers ~30 index resizes
|
||||
# and hundreds of persistDirty() calls. persistDirty uses relative seek
|
||||
# positioning in link_lists.bin; accumulated seek drift across resize cycles
|
||||
# causes the OS to extend the sparse file with zero-filled regions, each
|
||||
# cycle compounding the next. Result: link_lists.bin grows into hundreds of
|
||||
# GB sparse, after which `status`/`search`/`repair` segfault.
|
||||
# chromadb's Rust HNSW segment writes index_metadata.pickle and
|
||||
# link_lists.bin only when internal counters cross both thresholds
|
||||
# (batch_size gates _apply_batch; sync_threshold gates _persist).
|
||||
# Records below both thresholds stay in memory and are lost on exit.
|
||||
#
|
||||
# Setting large batch and sync thresholds at collection creation defers
|
||||
# persistence until a single large batch completes, breaking the resize+
|
||||
# persist feedback loop. Empirically validated on a 39,792-drawer rebuild
|
||||
# (palace 376 MB, link_lists.bin 0 bytes, no segfault) in 2026-04.
|
||||
# Previously 50k/50k to work around link_lists.bin sparse-file bloat
|
||||
# in pre-1.5.x Python chromadb (#344). chromadb >=1.5.4 Rust bindings
|
||||
# (the minimum mempalace supports) do not exhibit that bloat; verified
|
||||
# at batch_size=2 with 20k records: link_lists.bin = 171 KB, no
|
||||
# sparse-file inflation.
|
||||
#
|
||||
# Note: chromadb 1.5.x exposes a `collection.modify(configuration={"hnsw":
|
||||
# {"batch_size": ..., "sync_threshold": ...}})` retrofit path for already-
|
||||
# created collections (`UpdateHNSWConfiguration` in chromadb's API), but
|
||||
# this PR doesn't pursue that — once link_lists.bin has bloated, the index
|
||||
# is already corrupt and the only known recovery is a fresh mine.
|
||||
# The 50k guard caused #1579: mines under 50k drawers never triggered
|
||||
# _persist(), leaving index_metadata.pickle absent and link_lists.bin
|
||||
# empty. quarantine_stale_hnsw then renamed the segment on every cold
|
||||
# open after a 300s mtime gap, accumulating .drift-* directories.
|
||||
#
|
||||
# Lowered to 2 (empirical Rust-side minimum for chromadb >=1.5.4; the
|
||||
# Rust bindings reject 1 with InvalidArgumentError) so any mine of 2+
|
||||
# drawers triggers a natural persist. Existing palaces created under
|
||||
# the old 50k guard keep those thresholds in their collection metadata
|
||||
# until the user runs repair --mode from-sqlite --archive-existing.
|
||||
_HNSW_BLOAT_GUARD = {
|
||||
"hnsw:batch_size": 50_000,
|
||||
"hnsw:sync_threshold": 50_000,
|
||||
"hnsw:batch_size": 2,
|
||||
"hnsw:sync_threshold": 2,
|
||||
}
|
||||
|
||||
# Missing index_metadata.pickle is normal only while a segment is still fresh
|
||||
# or effectively empty. Once data_level0.bin has non-trivial payload, a
|
||||
# missing metadata pickle means the segment was interrupted after writing HNSW
|
||||
# data but before writing its metadata. Letting Chroma open that shape can
|
||||
# segfault or hang in native HNSW code.
|
||||
# Below this size, data_level0.bin is too small for a meaningful HNSW graph.
|
||||
# Used by _hnsw_link_lists_is_usable_for_payload (empty link_lists is fine
|
||||
# when data is trivially small) and _missing_dimensionality_appears_recoverable
|
||||
# (don't attempt recovery on segments with negligible data).
|
||||
_HNSW_MISSING_METADATA_DATA_FLOOR = 1024
|
||||
|
||||
|
||||
@@ -158,6 +168,127 @@ def _validate_where(where: Optional[dict]) -> None:
|
||||
stack.extend(x for x in v if isinstance(x, dict))
|
||||
|
||||
|
||||
def _tokenize(text: str) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
return _TOKEN_RE.findall(text.lower())
|
||||
|
||||
|
||||
def _bm25_scores(
|
||||
query: str,
|
||||
documents: list[str],
|
||||
k1: float = 1.5,
|
||||
b: float = 0.75,
|
||||
) -> list[float]:
|
||||
query_terms = set(_tokenize(query))
|
||||
n_docs = len(documents)
|
||||
if not query_terms or n_docs == 0:
|
||||
return [0.0] * n_docs
|
||||
|
||||
tokenized = [_tokenize(doc) for doc 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
|
||||
|
||||
df = {term: 0 for term in query_terms}
|
||||
for toks in tokenized:
|
||||
for term in set(toks) & query_terms:
|
||||
df[term] += 1
|
||||
|
||||
idf = {
|
||||
term: math.log((n_docs - df[term] + 0.5) / (df[term] + 0.5) + 1.0) for term in query_terms
|
||||
}
|
||||
|
||||
scores = []
|
||||
for toks, dl in zip(tokenized, doc_lens):
|
||||
if dl == 0:
|
||||
scores.append(0.0)
|
||||
continue
|
||||
tf: dict[str, int] = {}
|
||||
for token in toks:
|
||||
if token in query_terms:
|
||||
tf[token] = tf.get(token, 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 _coerce_metadata_value(value: Any) -> Any:
|
||||
if isinstance(value, bool):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def _compare_metadata(actual: Any, op: str, expected: Any) -> bool:
|
||||
actual = _coerce_metadata_value(actual)
|
||||
expected = _coerce_metadata_value(expected)
|
||||
if op == "$eq":
|
||||
return actual == expected
|
||||
if op == "$ne":
|
||||
return actual != expected
|
||||
if op == "$in":
|
||||
return actual in (expected or [])
|
||||
if op == "$nin":
|
||||
return actual not in (expected or [])
|
||||
if op == "$contains":
|
||||
return str(expected) in str(actual or "")
|
||||
try:
|
||||
if op == "$gt":
|
||||
return actual > expected
|
||||
if op == "$gte":
|
||||
return actual >= expected
|
||||
if op == "$lt":
|
||||
return actual < expected
|
||||
if op == "$lte":
|
||||
return actual <= expected
|
||||
except TypeError:
|
||||
return False
|
||||
raise UnsupportedFilterError(f"operator {op!r} not supported by chroma backend")
|
||||
|
||||
|
||||
def _matches_where(meta: dict, where: Optional[dict]) -> bool:
|
||||
if not where:
|
||||
return True
|
||||
if not isinstance(where, dict):
|
||||
return False
|
||||
for key, expected in where.items():
|
||||
if key == "$and":
|
||||
if not all(_matches_where(meta, clause) for clause in expected or []):
|
||||
return False
|
||||
continue
|
||||
if key == "$or":
|
||||
if not any(_matches_where(meta, clause) for clause in expected or []):
|
||||
return False
|
||||
continue
|
||||
if key.startswith("$"):
|
||||
raise UnsupportedFilterError(f"operator {key!r} not supported by chroma backend")
|
||||
actual = meta.get(key)
|
||||
if isinstance(expected, dict):
|
||||
for op, operand in expected.items():
|
||||
if not _compare_metadata(actual, op, operand):
|
||||
return False
|
||||
elif actual != expected:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _metadata_cell_value(sval, ival, fval, bval):
|
||||
if sval is not None:
|
||||
return sval
|
||||
if ival is not None:
|
||||
return ival
|
||||
if fval is not None:
|
||||
return fval
|
||||
if bval is not None:
|
||||
return bool(bval)
|
||||
return None
|
||||
|
||||
|
||||
def _segment_appears_healthy(seg_dir: str) -> bool:
|
||||
"""Return True if a chromadb HNSW segment dir looks intact.
|
||||
|
||||
@@ -168,11 +299,14 @@ def _segment_appears_healthy(seg_dir: str) -> bool:
|
||||
``0x2e`` (the protocol/terminator byte sequence chromadb serializes
|
||||
with).
|
||||
|
||||
Missing metadata is healthy only while the segment still looks fresh or
|
||||
empty. If ``data_level0.bin`` already has non-trivial payload but
|
||||
``index_metadata.pickle`` is missing, the segment is partially flushed:
|
||||
Chroma wrote vector data without the metadata it needs to reopen the
|
||||
HNSW reader safely.
|
||||
When metadata is missing, the segment is either *never-persisted*
|
||||
(sub-threshold: fewer records than ``batch_size``, so chromadb never
|
||||
triggered ``_persist()``) or *partially flushed* (persist started but
|
||||
crashed). The two are distinguished by ``link_lists.bin``: chromadb
|
||||
writes link data during persist, so an empty/absent ``link_lists.bin``
|
||||
together with absent metadata means no persist was ever attempted.
|
||||
Note: ``data_level0.bin`` is pre-allocated at index creation and its
|
||||
size does not indicate actual record count.
|
||||
|
||||
Deliberately format-sniffs only; never deserializes. Deserialization
|
||||
can execute arbitrary code, and the byte-sniff is sufficient to
|
||||
@@ -185,28 +319,23 @@ def _segment_appears_healthy(seg_dir: str) -> bool:
|
||||
files and quarantine_stale_hnsw would conservatively rename them
|
||||
out of the way.
|
||||
"""
|
||||
meta_path = os.path.join(seg_dir, "index_metadata.pickle")
|
||||
|
||||
if not os.path.isfile(meta_path):
|
||||
link_path = os.path.join(seg_dir, "link_lists.bin")
|
||||
try:
|
||||
link_has_data = os.path.isfile(link_path) and os.path.getsize(link_path) > 0
|
||||
except OSError:
|
||||
return False
|
||||
# Both absent → sub-threshold, never persisted.
|
||||
# link_lists written but metadata not → interrupted persist.
|
||||
return not link_has_data
|
||||
|
||||
if not _hnsw_payload_appears_sane(seg_dir):
|
||||
return False
|
||||
|
||||
meta_path = os.path.join(seg_dir, "index_metadata.pickle")
|
||||
if not os.path.isfile(meta_path):
|
||||
data_path = os.path.join(seg_dir, "data_level0.bin")
|
||||
try:
|
||||
if (
|
||||
os.path.isfile(data_path)
|
||||
and os.path.getsize(data_path) > _HNSW_MISSING_METADATA_DATA_FLOOR
|
||||
):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# No metadata and no meaningful vector payload yet: fresh/empty segment.
|
||||
return True
|
||||
|
||||
try:
|
||||
size = os.path.getsize(meta_path)
|
||||
# A real chromadb metadata file is at least tens of bytes; a
|
||||
# smaller-than-floor file is almost certainly truncated.
|
||||
if size < 16:
|
||||
return False
|
||||
with open(meta_path, "rb") as f:
|
||||
@@ -466,14 +595,15 @@ def _hnsw_element_count(palace_path: str, segment_id: str) -> Optional[int]:
|
||||
# read the collection metadata (older palaces missing the row, sqlite
|
||||
# unreadable). 2000 = 2 × chromadb's default sync_threshold of 1000.
|
||||
#
|
||||
# Why dynamic: PR #1191 set ``hnsw:sync_threshold = 50_000`` to prevent
|
||||
# index bloat, which means flush-lag can grow up to 50K naturally. A
|
||||
# fixed 2000 floor would flag every actively-written palace as DIVERGED
|
||||
# the moment its queue exceeded 10% of sqlite_count, even though chromadb
|
||||
# is behaving correctly. The floor must scale with sync_threshold to
|
||||
# distinguish real corruption (#1222 was 176 613 missing of 192 997 —
|
||||
# orders of magnitude past 2 × any reasonable sync_threshold) from
|
||||
# expected steady-state lag.
|
||||
# Why dynamic: legacy palaces may still carry ``sync_threshold = 50_000``
|
||||
# (the pre-#1579 guard), so flush-lag can grow up to 50K on those palaces.
|
||||
# New palaces use sync_threshold=2 (#1579) and flush almost immediately.
|
||||
# A fixed 2000 floor would flag actively-written legacy palaces as
|
||||
# DIVERGED the moment their queue exceeded 10% of sqlite_count, even
|
||||
# though chromadb is behaving correctly. The floor must scale with the
|
||||
# per-collection sync_threshold to distinguish real corruption (#1222 was
|
||||
# 176 613 missing of 192 997, orders of magnitude past any reasonable
|
||||
# sync_threshold) from expected steady-state lag.
|
||||
_HNSW_DIVERGENCE_FALLBACK_FLOOR = 2000
|
||||
_HNSW_DIVERGENCE_FRACTION = 0.10
|
||||
|
||||
@@ -635,6 +765,93 @@ def _sqlite_embedding_count(palace_path: str, collection_name: str) -> Optional[
|
||||
return None
|
||||
|
||||
|
||||
def _sqlite_wing_room_counts(
|
||||
palace_path: str, collection_name: str
|
||||
) -> Optional[tuple[int, dict[str, dict[str, int]]]]:
|
||||
"""Tally drawers by wing/room straight from ``chroma.sqlite3``.
|
||||
|
||||
Returns ``(total, {wing: {room: count}})`` or ``None`` when the read
|
||||
cannot be trusted — missing DB file, the collection has not been
|
||||
bootstrapped, or any sqlite error (including a sustained writer lock).
|
||||
``None`` signals the caller to fall back to the ChromaDB client path
|
||||
(which also emits the right state-specific guidance for absent/empty
|
||||
palaces).
|
||||
|
||||
The point of reading sqlite directly is to count drawers **without opening
|
||||
the collection**, because opening it cold-loads the HNSW vector index. On
|
||||
large palaces that load costs tens of seconds of CPU per call — a steep,
|
||||
pointless tax for an inspection command that only needs metadata the
|
||||
relational tables already hold (#1681). Wings/rooms live in plain
|
||||
``embedding_metadata`` rows, joined to ``embeddings`` on the
|
||||
``(id, key)`` primary key, so the tally is a bounded scan of the metadata
|
||||
segment: sub-second warm, a few seconds cold on a multi-GB DB — versus the
|
||||
~60s the vector-index load costs.
|
||||
|
||||
Sibling readers that count the same way: :func:`_sqlite_embedding_count`
|
||||
(total only) and ``mcp_server._tool_status_via_sqlite`` (independent
|
||||
wing/room histograms for the #1222 fallback). This one cross-tabulates
|
||||
wing→room to match the ChromaDB ``status()`` output shape.
|
||||
|
||||
Notes:
|
||||
- ``busy_timeout`` lets a transient checkpoint lock resolve instead of
|
||||
instantly demoting to the slow HNSW path; a *sustained* lock still
|
||||
raises and falls back (slow but correct).
|
||||
- ``s.scope = 'METADATA'`` makes the single-segment join explicit so a
|
||||
future ChromaDB that also stored per-vector-segment rows could not
|
||||
silently double every count.
|
||||
- ``COALESCE`` over ``string_value``/``int_value``/``float_value`` matches
|
||||
the ChromaDB path, which surfaces a numeric wing/room natively rather
|
||||
than dropping it to ``"?"``.
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return None
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
# Wait out a transient writer/checkpoint lock rather than falling
|
||||
# straight back to the expensive vector-index path (#1681).
|
||||
conn.execute("PRAGMA busy_timeout = 3000")
|
||||
# Distinguish "collection never bootstrapped" (-> None, so the
|
||||
# caller can show the 'initialized but empty' message) from
|
||||
# "collection exists with zero drawers" (-> a real 0 tally).
|
||||
if (
|
||||
conn.execute(
|
||||
"SELECT 1 FROM collections WHERE name = ?", (collection_name,)
|
||||
).fetchone()
|
||||
is None
|
||||
):
|
||||
return None
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT COALESCE(wm.string_value, CAST(wm.int_value AS TEXT),
|
||||
CAST(wm.float_value AS TEXT), '?') AS wing,
|
||||
COALESCE(rm.string_value, CAST(rm.int_value AS TEXT),
|
||||
CAST(rm.float_value AS TEXT), '?') AS room,
|
||||
COUNT(*) AS n
|
||||
FROM embeddings e
|
||||
JOIN segments s ON e.segment_id = s.id AND s.scope = 'METADATA'
|
||||
JOIN collections c ON s.collection = c.id
|
||||
LEFT JOIN embedding_metadata wm ON wm.id = e.id AND wm.key = 'wing'
|
||||
LEFT JOIN embedding_metadata rm ON rm.id = e.id AND rm.key = 'room'
|
||||
WHERE c.name = ?
|
||||
GROUP BY wing, room
|
||||
""",
|
||||
(collection_name,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
|
||||
total = 0
|
||||
wing_rooms: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
||||
for wing, room, n in rows:
|
||||
wing_rooms[wing][room] += int(n)
|
||||
total += int(n)
|
||||
return total, wing_rooms
|
||||
|
||||
|
||||
def _pin_hnsw_threads(collection) -> None:
|
||||
"""Best-effort retrofit: pin ``hnsw:num_threads=1`` on an existing collection.
|
||||
|
||||
@@ -665,6 +882,7 @@ def _pin_hnsw_threads(collection) -> None:
|
||||
|
||||
|
||||
_BLOB_FIX_MARKER = ".blob_seq_ids_migrated"
|
||||
_COLLECTION_TYPE_MARKER = ".collection_type_fixed"
|
||||
|
||||
|
||||
def _valid_dimensionality(value: object) -> bool:
|
||||
@@ -708,7 +926,12 @@ def _missing_dimensionality_appears_recoverable(
|
||||
return False
|
||||
|
||||
label_count = len(id_to_label)
|
||||
if int(total) != label_count or len(label_to_id) != label_count:
|
||||
# total_elements_added is monotonic across every add, while id_to_label and
|
||||
# label_to_id hold only live elements, so a segment that has had deletions
|
||||
# carries total_elements_added > label_count. Require >= (not ==), otherwise
|
||||
# every post-deletion dim-None segment is wrongly quarantined (#1710); the
|
||||
# label-map size and bijection checks still reject inconsistent label maps.
|
||||
if int(total) < label_count or len(label_to_id) != label_count:
|
||||
return False
|
||||
try:
|
||||
return all(label_to_id.get(label) == item_id for item_id, label in id_to_label.items())
|
||||
@@ -850,7 +1073,7 @@ def _fix_blob_seq_ids(palace_path: str) -> None:
|
||||
if os.path.isfile(marker):
|
||||
return
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
with contextlib.closing(sqlite3.connect(db_path)) as conn:
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT rowid, seq_id FROM embeddings WHERE typeof(seq_id) = 'blob'"
|
||||
@@ -883,6 +1106,69 @@ def _fix_blob_seq_ids(palace_path: str) -> None:
|
||||
logger.exception("Could not write migration marker %s", marker)
|
||||
|
||||
|
||||
def _fix_missing_collection_type(palace_path: str) -> None:
|
||||
"""Add ``_type`` to ``collections.config_json_str`` where absent.
|
||||
|
||||
chromadb <= 1.5.8 writes ``config_json_str = '{}'`` (empty JSON) when
|
||||
creating collections. chromadb 1.5.9 switched from the permissive
|
||||
``load_collection_configuration_from_json_str`` to
|
||||
``CollectionConfigurationInternal.from_json`` which requires a ``_type``
|
||||
key — its absence raises ``KeyError: '_type'`` on palace open.
|
||||
|
||||
This migration adds the missing marker so both old and new chromadb
|
||||
versions can load the collection. The value
|
||||
``"CollectionConfigurationInternal"`` matches what ``to_json()`` writes
|
||||
for freshly-created collections.
|
||||
|
||||
Same lifecycle constraints as :func:`_fix_blob_seq_ids`: must run
|
||||
BEFORE ``PersistentClient`` is created.
|
||||
"""
|
||||
db_path = os.path.join(palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return
|
||||
marker = os.path.join(palace_path, _COLLECTION_TYPE_MARKER)
|
||||
if os.path.isfile(marker):
|
||||
return
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
try:
|
||||
rows = conn.execute("SELECT id, config_json_str FROM collections").fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
return
|
||||
updates = []
|
||||
for coll_id, config_str in rows:
|
||||
if not config_str:
|
||||
config_str = "{}"
|
||||
try:
|
||||
config = json.loads(config_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
if "_type" not in config:
|
||||
config["_type"] = "CollectionConfigurationInternal"
|
||||
updates.append((json.dumps(config), coll_id))
|
||||
if updates:
|
||||
conn.executemany(
|
||||
"UPDATE collections SET config_json_str = ? WHERE id = ?",
|
||||
updates,
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"Fixed %d collection(s) missing _type in config_json_str",
|
||||
len(updates),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Could not fix collection config_json_str in %s", db_path)
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
Path(marker).touch()
|
||||
except OSError:
|
||||
logger.exception("Could not write migration marker %s", marker)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -973,8 +1259,40 @@ class ChromaCollection(BaseCollection):
|
||||
for m in metadatas
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_documents_for_chromadb(documents):
|
||||
"""Strip lone UTF-16 surrogates from every document before it reaches
|
||||
the chromadb client.
|
||||
|
||||
A single lone surrogate (U+D800–U+DFFF) raises ``UnicodeEncodeError``
|
||||
inside chromadb's encode path and aborts the *entire* add/upsert batch
|
||||
with a ``-32000`` Internal Error, silently dropping every other row in
|
||||
the same batch (#1235).
|
||||
|
||||
#1235 fixed this for the MCP write tools via ``sanitize_content``, but
|
||||
the bulk ingest paths (miner, convo_miner, sweeper, diary_ingest) build
|
||||
documents without routing through that helper and reach this backend
|
||||
directly. Sanitising here makes the chokepoint catch-all complete: the
|
||||
sibling :meth:`_sanitize_metadatas_for_chromadb` already guarantees this
|
||||
for metadata one method over; documents get the same guarantee.
|
||||
"""
|
||||
if documents is None:
|
||||
return None
|
||||
from ..config import strip_lone_surrogates
|
||||
|
||||
# chromadb accepts OneOrMany[Document]: a bare str is a single document,
|
||||
# not an iterable of characters. Handle it explicitly so we don't split
|
||||
# it into per-character documents — that would be exactly the kind of
|
||||
# silent corruption this method exists to prevent.
|
||||
if isinstance(documents, str):
|
||||
return strip_lone_surrogates(documents)
|
||||
return [strip_lone_surrogates(d) if isinstance(d, str) else d for d in documents]
|
||||
|
||||
def add(self, *, documents, ids, metadatas=None, embeddings=None):
|
||||
kwargs: dict[str, Any] = {"documents": documents, "ids": ids}
|
||||
kwargs: dict[str, Any] = {
|
||||
"documents": self._sanitize_documents_for_chromadb(documents),
|
||||
"ids": ids,
|
||||
}
|
||||
sanitized = self._sanitize_metadatas_for_chromadb(metadatas)
|
||||
if sanitized is not None:
|
||||
kwargs["metadatas"] = sanitized
|
||||
@@ -984,7 +1302,10 @@ class ChromaCollection(BaseCollection):
|
||||
self._collection.add(**kwargs)
|
||||
|
||||
def upsert(self, *, documents, ids, metadatas=None, embeddings=None):
|
||||
kwargs: dict[str, Any] = {"documents": documents, "ids": ids}
|
||||
kwargs: dict[str, Any] = {
|
||||
"documents": self._sanitize_documents_for_chromadb(documents),
|
||||
"ids": ids,
|
||||
}
|
||||
sanitized = self._sanitize_metadatas_for_chromadb(metadatas)
|
||||
if sanitized is not None:
|
||||
kwargs["metadatas"] = sanitized
|
||||
@@ -1005,7 +1326,7 @@ class ChromaCollection(BaseCollection):
|
||||
raise ValueError("update requires at least one of documents, metadatas, embeddings")
|
||||
kwargs: dict[str, Any] = {"ids": ids}
|
||||
if documents is not None:
|
||||
kwargs["documents"] = documents
|
||||
kwargs["documents"] = self._sanitize_documents_for_chromadb(documents)
|
||||
if metadatas is not None:
|
||||
kwargs["metadatas"] = metadatas
|
||||
if embeddings is not None:
|
||||
@@ -1161,6 +1482,242 @@ class ChromaCollection(BaseCollection):
|
||||
def count(self):
|
||||
return self._collection.count()
|
||||
|
||||
def lexical_search(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
n_results: int = 10,
|
||||
where: Optional[dict] = None,
|
||||
) -> LexicalResult:
|
||||
"""Return lexical BM25 candidates for this collection.
|
||||
|
||||
This is the normal healthy-Chroma implementation behind the optional
|
||||
backend capability. The HNSW-disabled fallback in ``searcher.py`` still
|
||||
reads ``chroma.sqlite3`` directly and remains Chroma-only.
|
||||
"""
|
||||
_validate_where(where)
|
||||
sqlite_hits = self._lexical_search_via_sqlite(query=query, n_results=n_results, where=where)
|
||||
if sqlite_hits is not None:
|
||||
return LexicalResult(hits=sqlite_hits)
|
||||
|
||||
# Directly-constructed ChromaCollection test doubles may not carry a
|
||||
# palace path. Keep lexical_search usable in that shape, but normal
|
||||
# MemPalace paths above use Chroma's FTS table instead of scanning every
|
||||
# drawer through the Python client.
|
||||
total = self.count()
|
||||
docs: list[str] = []
|
||||
metas: list[dict] = []
|
||||
ids: list[str] = []
|
||||
offset = 0
|
||||
batch_size = 1000
|
||||
while offset < total:
|
||||
kwargs: dict[str, Any] = {
|
||||
"include": ["documents", "metadatas"],
|
||||
"limit": batch_size,
|
||||
"offset": offset,
|
||||
}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
batch = self.get(**kwargs)
|
||||
if not batch.ids:
|
||||
break
|
||||
ids.extend(batch.ids)
|
||||
docs.extend(doc or "" for doc in batch.documents)
|
||||
metas.extend(meta or {} for meta in batch.metadatas)
|
||||
offset += len(batch.ids)
|
||||
|
||||
scores = _bm25_scores(query, docs)
|
||||
hits = [
|
||||
LexicalHit(id=doc_id, document=doc, metadata=meta, score=float(score))
|
||||
for doc_id, doc, meta, score in zip(ids, docs, metas, scores)
|
||||
if score > 0
|
||||
]
|
||||
hits.sort(key=lambda hit: hit.score, reverse=True)
|
||||
return LexicalResult(hits=hits[:n_results])
|
||||
|
||||
def _collection_name(self) -> Optional[str]:
|
||||
name = getattr(self._collection, "name", None)
|
||||
if callable(name):
|
||||
try:
|
||||
name = name()
|
||||
except TypeError:
|
||||
name = None
|
||||
return str(name) if name else None
|
||||
|
||||
def _lexical_search_via_sqlite(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
n_results: int,
|
||||
where: Optional[dict],
|
||||
max_candidates: int = 500,
|
||||
) -> Optional[list[LexicalHit]]:
|
||||
if not self._palace_path:
|
||||
return None
|
||||
db_path = os.path.join(self._palace_path, "chroma.sqlite3")
|
||||
if not os.path.isfile(db_path):
|
||||
return []
|
||||
collection_name = self._collection_name()
|
||||
if not collection_name:
|
||||
return []
|
||||
|
||||
tokens = [t for t in _tokenize(query) if len(t) >= 3]
|
||||
use_recency_fallback = not tokens
|
||||
candidate_ids: list[int] = []
|
||||
# Map internal embeddings.id (rowid, used to join embedding_metadata)
|
||||
# to the public embeddings.embedding_id so returned LexicalHit.id values
|
||||
# round-trip through get(ids=...). The two differ: id is the integer
|
||||
# rowid, embedding_id is the user-facing drawer id.
|
||||
public_ids: dict[int, str] = {}
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
except sqlite3.Error:
|
||||
logger.debug("Chroma lexical sqlite open failed", exc_info=True)
|
||||
return []
|
||||
|
||||
try:
|
||||
if tokens:
|
||||
fts_query = " OR ".join(tokens)
|
||||
# If a metadata filter is present, do not cap before filtering:
|
||||
# otherwise a common term can fill the window with wrong-scope
|
||||
# rows and hide valid scoped hits later in the FTS result set.
|
||||
limit_sql = "" if where else "LIMIT ?"
|
||||
params = [fts_query, collection_name]
|
||||
if not where:
|
||||
params.append(max(max_candidates, n_results))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT e.id, e.embedding_id
|
||||
FROM embedding_fulltext_search
|
||||
JOIN embeddings e ON e.id = embedding_fulltext_search.rowid
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE embedding_fulltext_search MATCH ?
|
||||
AND c.name = ?
|
||||
{limit_sql}
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
candidate_ids = [int(row[0]) for row in rows]
|
||||
public_ids.update({int(row[0]): str(row[1]) for row in rows})
|
||||
except sqlite3.Error:
|
||||
logger.debug(
|
||||
"Chroma lexical FTS query failed; using recency fallback", exc_info=True
|
||||
)
|
||||
use_recency_fallback = True
|
||||
|
||||
if not candidate_ids and use_recency_fallback:
|
||||
order_expr = "e.created_at DESC"
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT e.id, e.embedding_id
|
||||
FROM embeddings e
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE c.name = ?
|
||||
ORDER BY {order_expr}
|
||||
LIMIT ?
|
||||
""",
|
||||
(collection_name, max(max_candidates, n_results)),
|
||||
).fetchall()
|
||||
except sqlite3.Error:
|
||||
logger.debug(
|
||||
"Chroma lexical recency fallback failed; ordering by id", exc_info=True
|
||||
)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT e.id, e.embedding_id
|
||||
FROM embeddings e
|
||||
JOIN segments s ON e.segment_id = s.id
|
||||
JOIN collections c ON s.collection = c.id
|
||||
WHERE c.name = ?
|
||||
ORDER BY e.id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(collection_name, max(max_candidates, n_results)),
|
||||
).fetchall()
|
||||
candidate_ids = [int(row[0]) for row in rows]
|
||||
public_ids.update({int(row[0]): str(row[1]) for row in rows})
|
||||
|
||||
if not candidate_ids:
|
||||
return []
|
||||
|
||||
meta_columns = {
|
||||
row["name"]
|
||||
for row in conn.execute("PRAGMA table_info(embedding_metadata)").fetchall()
|
||||
}
|
||||
value_columns = [
|
||||
col
|
||||
for col in ("string_value", "int_value", "float_value", "bool_value")
|
||||
if col in meta_columns
|
||||
]
|
||||
if not value_columns:
|
||||
return []
|
||||
meta_rows = []
|
||||
for start in range(0, len(candidate_ids), 900):
|
||||
chunk_ids = candidate_ids[start : start + 900]
|
||||
placeholders = ",".join("?" for _ in chunk_ids)
|
||||
meta_rows.extend(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT id, key, {", ".join(value_columns)}
|
||||
FROM embedding_metadata
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
chunk_ids,
|
||||
).fetchall()
|
||||
)
|
||||
except sqlite3.Error:
|
||||
logger.debug("Chroma lexical sqlite read failed", exc_info=True)
|
||||
return []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
drawers: dict[int, dict] = {}
|
||||
for row in meta_rows:
|
||||
emb_id = int(row["id"])
|
||||
key = row["key"]
|
||||
values = {col: row[col] if col in row.keys() else None for col in value_columns}
|
||||
value = _metadata_cell_value(
|
||||
values.get("string_value"),
|
||||
values.get("int_value"),
|
||||
values.get("float_value"),
|
||||
values.get("bool_value"),
|
||||
)
|
||||
drawer = drawers.setdefault(emb_id, {"metadata": {}, "document": ""})
|
||||
if key == "chroma:document":
|
||||
drawer["document"] = str(value or "")
|
||||
else:
|
||||
drawer["metadata"][key] = value
|
||||
|
||||
ordered = []
|
||||
for emb_id in candidate_ids:
|
||||
drawer = drawers.get(emb_id)
|
||||
if drawer is None:
|
||||
continue
|
||||
meta = drawer["metadata"]
|
||||
if not _matches_where(meta, where):
|
||||
continue
|
||||
ordered.append((emb_id, drawer["document"], meta))
|
||||
|
||||
docs = [doc for _, doc, _ in ordered]
|
||||
scores = _bm25_scores(query, docs)
|
||||
hits = [
|
||||
LexicalHit(
|
||||
id=public_ids.get(emb_id, str(emb_id)),
|
||||
document=doc,
|
||||
metadata=meta,
|
||||
score=float(score),
|
||||
)
|
||||
for (emb_id, doc, meta), score in zip(ordered, scores)
|
||||
if score > 0
|
||||
]
|
||||
hits.sort(key=lambda hit: hit.score, reverse=True)
|
||||
return hits[:n_results]
|
||||
|
||||
@property
|
||||
def metadata(self) -> dict:
|
||||
"""Pass-through to the underlying ChromaDB collection's metadata.
|
||||
@@ -1173,6 +1730,46 @@ class ChromaCollection(BaseCollection):
|
||||
"""
|
||||
return self._collection.metadata or {}
|
||||
|
||||
@property
|
||||
def distance_metric(self) -> str:
|
||||
"""Report this collection's actual space from ``hnsw:space``.
|
||||
|
||||
MemPalace sets ``hnsw:space=cosine`` on every creation path, so a
|
||||
healthy palace reports ``"cosine"``. When the key is absent, empty, or
|
||||
an unrecognized value, the collection is genuinely using Chroma's HNSW
|
||||
default — **L2** (Euclidean) — because cosine was never set on it. We
|
||||
report ``"l2"`` in that case so core ranking maps the distances
|
||||
correctly; reporting ``"cosine"`` here would reintroduce the
|
||||
floor-every-result-to-zero misranking this property exists to fix.
|
||||
"""
|
||||
space = str(self.metadata.get("hnsw:space", "") or "").lower()
|
||||
if space in ("cosine", "l2", "ip"):
|
||||
return space
|
||||
return "l2"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Embedder identity (RFC 001)
|
||||
#
|
||||
# Stored in a small sidecar JSON in the palace dir rather than the Chroma
|
||||
# collection metadata: ``collection.modify(metadata=...)`` replaces the
|
||||
# whole dict and some Chroma versions reject re-passing the immutable
|
||||
# ``hnsw:*`` construction keys, so mutating it on every open is fragile.
|
||||
# The sidecar is keyed by collection name (a palace may hold several).
|
||||
# This is complementary to Chroma's own embedding-function-name check —
|
||||
# the core check runs at open time and yields the clean cross-backend
|
||||
# error before Chroma's read-time rejection fires.
|
||||
# ------------------------------------------------------------------
|
||||
def _embedder_sidecar_path(self) -> Optional[str]:
|
||||
if not self._palace_path:
|
||||
return None
|
||||
return os.path.join(self._palace_path, EMBEDDER_SIDECAR_FILENAME)
|
||||
|
||||
def get_stored_embedder_identity(self):
|
||||
return read_embedder_sidecar(self._embedder_sidecar_path(), self._collection_name())
|
||||
|
||||
def set_embedder_identity(self, identity) -> None:
|
||||
write_embedder_sidecar(self._embedder_sidecar_path(), self._collection_name(), identity)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend
|
||||
@@ -1199,6 +1796,7 @@ class ChromaBackend(BaseBackend):
|
||||
"supports_embeddings_out",
|
||||
"supports_metadata_filters",
|
||||
"supports_contains_fast",
|
||||
"supports_lexical_search",
|
||||
"local_mode",
|
||||
}
|
||||
)
|
||||
@@ -1318,12 +1916,17 @@ class ChromaBackend(BaseBackend):
|
||||
)
|
||||
|
||||
if cached is None or inode_changed or mtime_changed or mtime_appeared:
|
||||
# An inode swap means we are reopening a different physical DB
|
||||
# (post-restore, fresh palace at the same path, etc.); drop the
|
||||
# per-process gate so the quarantine pre-checks run again
|
||||
# against the new disk state instead of trusting cached "we
|
||||
# already cleaned this path" credit from the prior inode.
|
||||
if inode_changed:
|
||||
# Drop the per-process quarantine gate so the HNSW pre-checks
|
||||
# run again against the new disk state. An inode swap means a
|
||||
# different physical DB (post-restore, fresh palace at the same
|
||||
# path); an mtime/appearance change means an external in-place
|
||||
# write (closet_llm, mine, compress) that may have drifted the
|
||||
# HNSW index while this process was running.
|
||||
if (
|
||||
inode_changed
|
||||
or mtime_changed
|
||||
or (mtime_appeared and palace_path in self._freshness)
|
||||
):
|
||||
ChromaBackend._quarantined_paths.discard(palace_path)
|
||||
ChromaBackend._prepare_palace_for_open(palace_path)
|
||||
cached = chromadb.PersistentClient(path=palace_path)
|
||||
@@ -1340,17 +1943,12 @@ class ChromaBackend(BaseBackend):
|
||||
|
||||
# Per-process record of palaces that have already had the cold-start
|
||||
# quarantine invoked at least once. The proactive HNSW checks are a
|
||||
# *cold-start* protection — they catch segments that arrive stale relative
|
||||
# *cold-start* protection -- they catch segments that arrive stale relative
|
||||
# to ``chroma.sqlite3`` or invalid on disk (e.g. cross-machine replication,
|
||||
# partial restore, crashed-mid-write). Once a long-running process has
|
||||
# opened the palace cleanly, re-firing the stale check on every reconnect
|
||||
# is a *runtime thrash*: the daemon's own writes bump sqlite mtime but HNSW
|
||||
# flushes batch on chromadb's internal cadence, so the mtime gap naturally
|
||||
# exceeds the threshold under steady write load even though nothing is
|
||||
# corrupt.
|
||||
# Real runtime drift is still handled — palace-daemon's ``_auto_repair``
|
||||
# calls :func:`quarantine_stale_hnsw` directly on observed HNSW errors,
|
||||
# which bypasses this gate.
|
||||
# partial restore, crashed-mid-write). The gate is cleared whenever the
|
||||
# palace changes on disk (inode swap, mtime bump, or file appearance), so
|
||||
# external writes that drift HNSW segments are caught on the next open
|
||||
# without requiring a full process restart.
|
||||
#
|
||||
# Thread-safety: this set is mutated without a lock. Two concurrent
|
||||
# ``make_client()`` calls for the same palace can both pass the
|
||||
@@ -1366,25 +1964,29 @@ class ChromaBackend(BaseBackend):
|
||||
"""Run the pre-open safety pass shared by :meth:`make_client` and
|
||||
:meth:`_client`.
|
||||
|
||||
Three steps, all required before constructing a ``PersistentClient``:
|
||||
Four steps, all required before constructing a ``PersistentClient``:
|
||||
|
||||
1. ``_fix_blob_seq_ids`` — repairs the BLOB seq_id quirk that bites
|
||||
1. ``_fix_missing_collection_type`` — adds the ``_type`` marker to
|
||||
``collections.config_json_str`` that chromadb 1.5.9+ requires
|
||||
but <= 1.5.8 never wrote (#1611).
|
||||
2. ``_fix_blob_seq_ids`` — repairs the BLOB seq_id quirk that bites
|
||||
certain chromadb migrations.
|
||||
2. ``quarantine_invalid_hnsw_metadata`` — renames aside any HNSW
|
||||
3. ``quarantine_invalid_hnsw_metadata`` — renames aside any HNSW
|
||||
``index_metadata.pickle`` that fails to load, so chromadb opens
|
||||
against an empty index instead of crashing on the unloadable
|
||||
pickle (#1266 / PR #1285).
|
||||
3. ``quarantine_stale_hnsw`` — also gated by :attr:`_quarantined_paths`
|
||||
so it fires once per palace per process. This is the SIGSEGV
|
||||
prevention path for stale HNSW segments (see #1121, #1132, #1263);
|
||||
wiring it through this helper means CLI mining, search, repair,
|
||||
and status all benefit, not just the legacy ``make_client``
|
||||
callers.
|
||||
4. ``quarantine_stale_hnsw`` -- gated by :attr:`_quarantined_paths`
|
||||
so it fires once per palace until the gate is re-armed by a
|
||||
disk change. This is the SIGSEGV prevention path for stale
|
||||
HNSW segments (see #1121, #1132, #1263); wiring it through
|
||||
this helper means CLI mining, search, repair, and status all
|
||||
benefit, not just the legacy ``make_client`` callers.
|
||||
|
||||
Idempotent: safe to call from any code path that is about to open or
|
||||
re-open a palace. The ``_quarantined_paths`` gate prevents thrash on
|
||||
hot paths (e.g. ``_client()`` is called on every backend operation).
|
||||
"""
|
||||
_fix_missing_collection_type(palace_path)
|
||||
_fix_blob_seq_ids(palace_path)
|
||||
if palace_path not in ChromaBackend._quarantined_paths:
|
||||
quarantine_invalid_hnsw_metadata(palace_path)
|
||||
@@ -1393,15 +1995,14 @@ class ChromaBackend(BaseBackend):
|
||||
|
||||
@staticmethod
|
||||
def make_client(palace_path: str):
|
||||
"""Create a fresh ``PersistentClient`` (fixes BLOB seq_ids first).
|
||||
"""Create a fresh ``PersistentClient`` (runs pre-open safety pass first).
|
||||
|
||||
Deprecated-ish: exposed for legacy long-lived callers that manage their
|
||||
own client cache. New code should obtain a collection through
|
||||
:meth:`get_collection` which manages caching internally.
|
||||
|
||||
Quarantines HNSW segments **once per palace per process**. See
|
||||
:attr:`_quarantined_paths` for the rationale (cold-start protection
|
||||
vs. runtime thrash on steady-write daemons).
|
||||
Quarantines HNSW segments on first open and after any detected
|
||||
disk change. See :attr:`_quarantined_paths` for the gate logic.
|
||||
"""
|
||||
ChromaBackend._prepare_palace_for_open(palace_path)
|
||||
return chromadb.PersistentClient(path=palace_path)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Core-side embedding adapter for explicit-vector backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import BaseCollection
|
||||
|
||||
|
||||
def _embed_texts(texts: list[str]) -> list[list[float]]:
|
||||
"""Embed ``texts`` with the configured local embedding function."""
|
||||
if not texts:
|
||||
return []
|
||||
from ..embedding import get_embedding_function
|
||||
|
||||
ef = get_embedding_function()
|
||||
vectors = ef(input=texts)
|
||||
return [list(v) for v in vectors]
|
||||
|
||||
|
||||
def _as_list(value):
|
||||
"""Normalize ChromaDB's ``OneOrMany`` shape (``str`` | ``dict`` | sequence) to a list.
|
||||
|
||||
A bare ``str`` (a document/id) or ``dict`` (a single metadata) must be
|
||||
*wrapped*, not iterated: ``list("abc")`` yields ``['a', 'b', 'c']`` and
|
||||
``list({"k": 1})`` yields ``['k']`` — either desyncs embeddings/metadatas
|
||||
from ``ids`` on explicit-vector backends (pgvector, sqlite_exact). A list is
|
||||
returned unchanged (no copy); any other iterable is materialized once.
|
||||
See PR #1706/#1707 review.
|
||||
"""
|
||||
if isinstance(value, (str, dict)):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return list(value)
|
||||
|
||||
|
||||
class EmbeddingCollection(BaseCollection):
|
||||
"""Wrap a collection that requires explicit vectors.
|
||||
|
||||
Backends opt in with the ``requires_explicit_embeddings`` capability.
|
||||
Core callers can keep using ``documents=`` and ``query_texts=``; this
|
||||
wrapper computes vectors locally before delegating to the backend.
|
||||
"""
|
||||
|
||||
def __init__(self, inner: BaseCollection):
|
||||
self._inner = inner
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._inner, name)
|
||||
|
||||
@property
|
||||
def distance_metric(self) -> str:
|
||||
# Explicit delegation: ``BaseCollection`` defines ``distance_metric``
|
||||
# as a property, so it resolves on this subclass and ``__getattr__``
|
||||
# never fires — without this override the wrapper would report the
|
||||
# base "cosine" default and mask a wrapped non-cosine backend.
|
||||
return self._inner.distance_metric
|
||||
|
||||
# Same shadowing reason as ``distance_metric``: these are concrete methods
|
||||
# on ``BaseCollection``, so ``__getattr__`` never delegates them. Forward
|
||||
# explicitly to the wrapped backend collection's identity store.
|
||||
def get_stored_embedder_identity(self):
|
||||
return self._inner.get_stored_embedder_identity()
|
||||
|
||||
def set_embedder_identity(self, identity) -> None:
|
||||
return self._inner.set_embedder_identity(identity)
|
||||
|
||||
def effective_embedder_identity(self):
|
||||
return self._inner.effective_embedder_identity()
|
||||
|
||||
def maintenance_state(self) -> dict:
|
||||
return self._inner.maintenance_state()
|
||||
|
||||
def run_maintenance(self, kind: str):
|
||||
return self._inner.run_maintenance(kind)
|
||||
|
||||
def add(self, *, documents, ids, metadatas=None, embeddings=None):
|
||||
documents = _as_list(documents)
|
||||
ids = _as_list(ids)
|
||||
if metadatas is not None:
|
||||
metadatas = _as_list(metadatas)
|
||||
if embeddings is None:
|
||||
embeddings = _embed_texts(documents)
|
||||
return self._inner.add(
|
||||
documents=documents,
|
||||
ids=ids,
|
||||
metadatas=metadatas,
|
||||
embeddings=embeddings,
|
||||
)
|
||||
|
||||
def upsert(self, *, documents, ids, metadatas=None, embeddings=None):
|
||||
documents = _as_list(documents)
|
||||
ids = _as_list(ids)
|
||||
if metadatas is not None:
|
||||
metadatas = _as_list(metadatas)
|
||||
if embeddings is None:
|
||||
embeddings = _embed_texts(documents)
|
||||
return self._inner.upsert(
|
||||
documents=documents,
|
||||
ids=ids,
|
||||
metadatas=metadatas,
|
||||
embeddings=embeddings,
|
||||
)
|
||||
|
||||
def query(
|
||||
self,
|
||||
*,
|
||||
query_texts: Optional[list[str] | str] = None,
|
||||
query_embeddings: Optional[list[list[float]]] = None,
|
||||
n_results: int = 10,
|
||||
where: Optional[dict] = None,
|
||||
where_document: Optional[dict] = None,
|
||||
include: Optional[list[str]] = None,
|
||||
):
|
||||
if query_texts is not None and query_embeddings is None:
|
||||
query_embeddings = _embed_texts(_as_list(query_texts))
|
||||
query_texts = None
|
||||
return self._inner.query(
|
||||
query_texts=query_texts,
|
||||
query_embeddings=query_embeddings,
|
||||
n_results=n_results,
|
||||
where=where,
|
||||
where_document=where_document,
|
||||
include=include,
|
||||
)
|
||||
|
||||
def get(
|
||||
self, *, ids=None, where=None, where_document=None, limit=None, offset=None, include=None
|
||||
):
|
||||
return self._inner.get(
|
||||
ids=ids,
|
||||
where=where,
|
||||
where_document=where_document,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
include=include,
|
||||
)
|
||||
|
||||
def delete(self, *, ids=None, where=None):
|
||||
return self._inner.delete(ids=ids, where=where)
|
||||
|
||||
def count(self) -> int:
|
||||
return self._inner.count()
|
||||
|
||||
def estimated_count(self) -> int:
|
||||
return self._inner.estimated_count()
|
||||
|
||||
def close(self) -> None:
|
||||
return self._inner.close()
|
||||
|
||||
def health(self):
|
||||
return self._inner.health()
|
||||
|
||||
def lexical_search(self, *, query: str, n_results: int = 10, where: Optional[dict] = None):
|
||||
return self._inner.lexical_search(query=query, n_results=n_results, where=where)
|
||||
|
||||
def update(self, *, ids, documents=None, metadatas=None, embeddings=None):
|
||||
ids = _as_list(ids)
|
||||
if documents is not None:
|
||||
documents = _as_list(documents)
|
||||
if embeddings is None:
|
||||
embeddings = _embed_texts(documents)
|
||||
if metadatas is not None:
|
||||
metadatas = _as_list(metadatas)
|
||||
return self._inner.update(
|
||||
ids=ids,
|
||||
documents=documents,
|
||||
metadatas=metadatas,
|
||||
embeddings=embeddings,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -125,6 +125,38 @@ def get_backend(name: str) -> BaseBackend:
|
||||
return inst
|
||||
|
||||
|
||||
def detect_backends_for_path(path: str) -> list[str]:
|
||||
"""Return all registered backend names whose artifacts are present at ``path``.
|
||||
|
||||
Detection is a migration/protection aid for local palaces. Backends are
|
||||
checked in registry-name order so callers get deterministic diagnostics if
|
||||
a broken directory contains artifacts from more than one backend.
|
||||
"""
|
||||
_discover_entry_points()
|
||||
detected = []
|
||||
for name in sorted(_registry):
|
||||
cls = _registry[name]
|
||||
try:
|
||||
if cls.detect(path):
|
||||
detected.append(name)
|
||||
except Exception:
|
||||
logger.exception("detect() raised on backend %r", name)
|
||||
return detected
|
||||
|
||||
|
||||
def detect_backend_for_path(path: str) -> Optional[str]:
|
||||
"""Return the single detected backend at ``path``, or ``None``.
|
||||
|
||||
If multiple backend artifacts are present, the first name in registry order
|
||||
is returned for backward compatibility. Callers that enforce mismatch
|
||||
protection should use :func:`detect_backends_for_path`.
|
||||
"""
|
||||
detected = detect_backends_for_path(path)
|
||||
if detected:
|
||||
return detected[0]
|
||||
return None
|
||||
|
||||
|
||||
def reset_backends() -> None:
|
||||
"""Close and drop all cached backend instances (primarily for tests)."""
|
||||
with _lock:
|
||||
@@ -161,14 +193,9 @@ def resolve_backend_for_palace(
|
||||
return candidate
|
||||
|
||||
_discover_entry_points()
|
||||
if palace_path:
|
||||
for name, cls in _registry.items():
|
||||
try:
|
||||
if cls.detect(palace_path):
|
||||
return name
|
||||
except Exception:
|
||||
logger.exception("detect() raised on backend %r", name)
|
||||
continue
|
||||
detected = detect_backend_for_path(palace_path) if palace_path else None
|
||||
if detected:
|
||||
return detected
|
||||
return default
|
||||
|
||||
|
||||
@@ -180,10 +207,19 @@ def resolve_backend_for_palace(
|
||||
def _register_builtins() -> None:
|
||||
"""Register chroma as the in-tree default."""
|
||||
from .chroma import ChromaBackend
|
||||
from .pgvector import PgVectorBackend
|
||||
from .qdrant import QdrantBackend
|
||||
from .sqlite_exact import SQLiteExactBackend
|
||||
|
||||
# Use setdefault semantics so a caller that pre-registered for tests wins.
|
||||
if "chroma" not in _registry:
|
||||
_registry["chroma"] = ChromaBackend
|
||||
if "qdrant" not in _registry:
|
||||
_registry["qdrant"] = QdrantBackend
|
||||
if "sqlite_exact" not in _registry:
|
||||
_registry["sqlite_exact"] = SQLiteExactBackend
|
||||
if "pgvector" not in _registry:
|
||||
_registry["pgvector"] = PgVectorBackend
|
||||
|
||||
|
||||
_register_builtins()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
"""Retention pruning for timestamped palace backups.
|
||||
|
||||
``mempalace migrate`` and ``mempalace repair max-seq-id`` each write a fresh,
|
||||
timestamped backup every time they run and historically never deleted the old
|
||||
ones. On a machine that mines or repairs on a schedule those full-size copies
|
||||
accumulate silently — a real palace was found with hundreds of gigabytes of
|
||||
backups sitting beside only a few hundred megabytes of live data, nearly
|
||||
filling the disk. This module prunes the backup set down to a bounded count
|
||||
after each new backup is written.
|
||||
|
||||
The retention count comes from ``MempalaceConfig.max_backups`` (default 10).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
|
||||
|
||||
def prune_backups(pattern, max_backups, *, log=None):
|
||||
"""Delete the oldest backups matching ``pattern`` so at most ``max_backups`` remain.
|
||||
|
||||
Args:
|
||||
pattern: A glob pattern matching the backup paths (files or
|
||||
directories). The caller is responsible for ``glob.escape``-ing
|
||||
any literal, non-wildcard portion that can contain glob
|
||||
metacharacters — palace paths sometimes do (e.g. a ``[``).
|
||||
max_backups: Number of most-recent backups to keep. ``None`` or any
|
||||
value ``<= 0`` disables pruning and returns immediately, so a
|
||||
backup set is never touched when the user has opted out.
|
||||
log: Optional callable (e.g. ``print``) for human-readable progress.
|
||||
|
||||
Returns:
|
||||
The list of paths that were successfully removed.
|
||||
|
||||
Recency is determined by filesystem mtime rather than by parsing the
|
||||
timestamp out of the name, so it stays correct even when two backup
|
||||
producers use different timestamp formats. Deletion failures are logged
|
||||
and skipped: pruning is best-effort cleanup and must never abort the
|
||||
migrate/repair operation that just completed successfully.
|
||||
"""
|
||||
if max_backups is None or max_backups <= 0:
|
||||
return []
|
||||
|
||||
scored = []
|
||||
for path in glob.glob(pattern):
|
||||
try:
|
||||
scored.append((os.path.getmtime(path), path))
|
||||
except OSError:
|
||||
# Vanished between glob and stat (concurrent prune / cleanup);
|
||||
# nothing for us to remove.
|
||||
continue
|
||||
|
||||
if len(scored) <= max_backups:
|
||||
return []
|
||||
|
||||
# Newest first; the path breaks mtime ties so ordering is deterministic.
|
||||
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||||
|
||||
removed = []
|
||||
for _mtime, path in scored[max_backups:]:
|
||||
try:
|
||||
if os.path.isdir(path) and not os.path.islink(path):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
os.remove(path)
|
||||
except OSError as exc:
|
||||
if log:
|
||||
log(f" Backup prune: could not remove {path}: {exc}")
|
||||
continue
|
||||
removed.append(path)
|
||||
if log:
|
||||
log(f" Backup prune: removed old backup {path}")
|
||||
|
||||
return removed
|
||||
+223
-16
@@ -51,6 +51,45 @@ _PASS_ZERO_PER_FILE_CAP = 100_000 # 100KB per file is generous for prose
|
||||
_PASS_ZERO_TOTAL_CAP = 5_000_000 # 5MB total ceiling — bounds memory
|
||||
_PASS_ZERO_LLM_PER_SAMPLE = 2_000 # for Tier 2 LLM call only
|
||||
_PASS_ZERO_LLM_MAX_SAMPLES = 20 # caps the LLM-tier sample count
|
||||
_EXPLICIT_BACKEND_ENV = "MEMPALACE_BACKEND_EXPLICIT"
|
||||
|
||||
|
||||
def _backend_arg(args):
|
||||
"""Return a CLI-selected backend from subcommand or global flags."""
|
||||
return getattr(args, "backend", None) or getattr(args, "global_backend", None)
|
||||
|
||||
|
||||
def _apply_backend_arg(args) -> None:
|
||||
backend = _backend_arg(args)
|
||||
if not backend:
|
||||
return
|
||||
backend = str(backend).strip().lower()
|
||||
from .backends import get_backend_class
|
||||
|
||||
get_backend_class(backend)
|
||||
os.environ[_EXPLICIT_BACKEND_ENV] = backend
|
||||
os.environ["MEMPALACE_BACKEND"] = backend
|
||||
|
||||
|
||||
def _selected_backend_for_palace(palace_path: str) -> str:
|
||||
from .palace import resolve_backend_name
|
||||
|
||||
return resolve_backend_name(palace_path, explicit=os.environ.get(_EXPLICIT_BACKEND_ENV))
|
||||
|
||||
|
||||
def _maintenance_requires_chroma(palace_path: str, command_name: str) -> bool:
|
||||
try:
|
||||
backend_name = _selected_backend_for_palace(palace_path)
|
||||
except Exception as exc: # noqa: BLE001 - user-facing guard before maintenance imports
|
||||
print(f"\n {command_name} cannot resolve the palace backend: {exc}", file=sys.stderr)
|
||||
return False
|
||||
if backend_name == "chroma":
|
||||
return True
|
||||
print(
|
||||
f"\n {command_name} is Chroma-only in this release (selected backend: {backend_name}).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _gather_origin_samples(project_dir) -> list:
|
||||
@@ -380,6 +419,9 @@ def cmd_init(args):
|
||||
# Pass 2: detect rooms from folder structure
|
||||
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
|
||||
cfg.init()
|
||||
backend = _backend_arg(args)
|
||||
if backend:
|
||||
cfg.set_backend(backend)
|
||||
|
||||
# Pass 3: protect git repos from accidentally committing per-project files
|
||||
_ensure_mempalace_files_gitignored(args.dir)
|
||||
@@ -615,6 +657,8 @@ def cmd_sync(args):
|
||||
"""Prune drawers whose source files are gitignored, deleted, or moved (#1252)."""
|
||||
from .mcp_server import _wal_log
|
||||
from .palace import MineAlreadyRunning
|
||||
from .backends import detect_backend_for_path
|
||||
from .palace import _backend_artifact_label, resolve_backend_name
|
||||
from .sync import sync_palace
|
||||
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
@@ -622,8 +666,16 @@ def cmd_sync(args):
|
||||
if not os.path.isdir(palace_path):
|
||||
print(f"\n No palace found at {palace_path}")
|
||||
return
|
||||
if not os.path.isfile(os.path.join(palace_path, "chroma.sqlite3")):
|
||||
print(f"\n Palace dir at {palace_path} exists but has no chroma.sqlite3 yet.")
|
||||
try:
|
||||
backend_name = resolve_backend_name(palace_path)
|
||||
except Exception as exc: # noqa: BLE001 - user-facing CLI guard
|
||||
print(f"\n Could not resolve palace backend: {exc}", file=sys.stderr)
|
||||
return
|
||||
if detect_backend_for_path(palace_path) is None:
|
||||
print(
|
||||
f"\n Palace dir at {palace_path} exists but has no "
|
||||
f"{_backend_artifact_label(backend_name)} yet."
|
||||
)
|
||||
print(" Run: mempalace mine <dir>")
|
||||
return
|
||||
|
||||
@@ -748,9 +800,11 @@ def cmd_split(args):
|
||||
|
||||
def cmd_migrate(args):
|
||||
"""Migrate palace from a different ChromaDB version."""
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
if not _maintenance_requires_chroma(palace_path, "migrate"):
|
||||
raise SystemExit(2)
|
||||
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,
|
||||
@@ -758,6 +812,18 @@ def cmd_migrate(args):
|
||||
)
|
||||
|
||||
|
||||
def cmd_migrate_wings(args):
|
||||
"""Normalize legacy wing names (strip leading/trailing separators)."""
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
from .migrate import migrate_wing_names
|
||||
|
||||
migrate_wing_names(
|
||||
palace_path=palace_path,
|
||||
dry_run=args.dry_run,
|
||||
confirm=getattr(args, "yes", False),
|
||||
)
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
from .miner import status
|
||||
|
||||
@@ -765,16 +831,78 @@ def cmd_status(args):
|
||||
status(palace_path=palace_path)
|
||||
|
||||
|
||||
def cmd_palace_set_embedder(args):
|
||||
"""Record (or force-override) a palace's embedder identity (RFC 001).
|
||||
|
||||
Resolves the ``unknown`` state for a legacy palace, or records a specific
|
||||
model with ``--model``. It records identity on the palace only; it does not
|
||||
change the configured model — when the two differ it prints how to align
|
||||
``MEMPALACE_EMBEDDING_MODEL``. ``--force`` overwrites an existing,
|
||||
differently-named identity.
|
||||
"""
|
||||
from .backends.base import EmbedderIdentityMismatchError
|
||||
from .palace import set_palace_embedder_identity
|
||||
|
||||
config = MempalaceConfig()
|
||||
palace_path = os.path.abspath(
|
||||
os.path.expanduser(args.palace) if args.palace else config.palace_path
|
||||
)
|
||||
model = getattr(args, "model", None)
|
||||
try:
|
||||
old, new = set_palace_embedder_identity(
|
||||
palace_path,
|
||||
model=model,
|
||||
force=getattr(args, "force", False),
|
||||
backend=_backend_arg(args),
|
||||
)
|
||||
except EmbedderIdentityMismatchError as exc:
|
||||
print(f" ✗ {exc}")
|
||||
raise SystemExit(2) from exc
|
||||
if old is None:
|
||||
print(f" ✓ recorded embedder identity: {new.model_name} (dim={new.dimension})")
|
||||
elif old.model_name == new.model_name:
|
||||
print(f" ✓ embedder identity unchanged: {new.model_name} (dim={new.dimension})")
|
||||
else:
|
||||
print(
|
||||
f" ✓ embedder identity changed: {old.model_name} → {new.model_name} "
|
||||
f"(dim={new.dimension})"
|
||||
)
|
||||
# set-embedder records the palace's identity; it does not change the
|
||||
# configured model. If they differ, the next normal open would mismatch —
|
||||
# tell the user how to align them.
|
||||
configured = config.embedding_model
|
||||
if new.model_name and configured and new.model_name != configured:
|
||||
print(
|
||||
f" ⚠ configured model is {configured!r}; set MEMPALACE_EMBEDDING_MODEL="
|
||||
f"{new.model_name} (or run onboarding) so normal opens of this palace match."
|
||||
)
|
||||
|
||||
|
||||
def cmd_repair_status(args):
|
||||
"""Read-only HNSW capacity health check (#1222)."""
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
if not _maintenance_requires_chroma(palace_path, "repair-status"):
|
||||
raise SystemExit(2)
|
||||
from .repair import status as repair_status
|
||||
|
||||
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
|
||||
repair_status(palace_path=palace_path)
|
||||
|
||||
|
||||
def cmd_repair(args):
|
||||
"""Rebuild palace vector index from SQLite metadata."""
|
||||
"""Rebuild palace vector index from SQLite metadata.
|
||||
|
||||
On success the palace SQLite file is VACUUMed and the FTS5 index is
|
||||
rebuilt, so the next repair's integrity preflight reads a consistent
|
||||
database (#1747).
|
||||
"""
|
||||
config = MempalaceConfig()
|
||||
collection_name = config.collection_name
|
||||
palace_path = os.path.abspath(
|
||||
os.path.expanduser(args.palace) if args.palace else config.palace_path
|
||||
)
|
||||
if not _maintenance_requires_chroma(palace_path, "repair"):
|
||||
raise SystemExit(2)
|
||||
|
||||
import shutil
|
||||
from .backends.chroma import ChromaBackend
|
||||
from .migrate import confirm_destructive_action, contains_palace_database
|
||||
@@ -783,6 +911,7 @@ def cmd_repair(args):
|
||||
TruncationDetected,
|
||||
_close_chroma_handles,
|
||||
_extract_drawers,
|
||||
_post_rebuild_cleanup,
|
||||
_rebuild_collection_via_temp,
|
||||
check_extraction_safety,
|
||||
maybe_repair_poisoned_max_seq_id_before_rebuild,
|
||||
@@ -790,12 +919,6 @@ def cmd_repair(args):
|
||||
sqlite_integrity_errors,
|
||||
)
|
||||
|
||||
config = MempalaceConfig()
|
||||
collection_name = config.collection_name
|
||||
palace_path = os.path.abspath(
|
||||
os.path.expanduser(args.palace) if args.palace else config.palace_path
|
||||
)
|
||||
|
||||
if getattr(args, "mode", "legacy") == "max-seq-id":
|
||||
from .repair import repair_max_seq_id
|
||||
|
||||
@@ -982,6 +1105,10 @@ def cmd_repair(args):
|
||||
print(f" Backup location: {backup_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# The bulk delete + re-upsert cycle above leaves the FTS5 inverted index
|
||||
# inconsistent, which fails the next repair's integrity preflight (#1747).
|
||||
_post_rebuild_cleanup(palace_path, backend=backend, progress=print)
|
||||
|
||||
print(f"\n Repair complete. {filed} drawers rebuilt.")
|
||||
print(f" Backup saved at {backup_path}")
|
||||
print(f"\n{'=' * 55}\n")
|
||||
@@ -1004,12 +1131,15 @@ def cmd_instructions(args):
|
||||
def cmd_mcp(args):
|
||||
"""Show how to wire MemPalace into MCP-capable hosts."""
|
||||
base_server_cmd = "mempalace-mcp"
|
||||
cmd_parts = [base_server_cmd]
|
||||
|
||||
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
|
||||
cmd_parts.extend(["--palace", shlex.quote(resolved_palace)])
|
||||
backend = _backend_arg(args)
|
||||
if backend:
|
||||
cmd_parts.extend(["--backend", shlex.quote(str(backend).strip().lower())])
|
||||
server_cmd = " ".join(cmd_parts)
|
||||
|
||||
print("MemPalace MCP quick setup:")
|
||||
print(f" claude mcp add mempalace -- {server_cmd}")
|
||||
@@ -1204,12 +1334,23 @@ def main():
|
||||
default=None,
|
||||
help="Where the palace lives (default: from ~/.mempalace/config.json or ~/.mempalace/palace)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
dest="global_backend",
|
||||
default=None,
|
||||
help="Storage backend to use for this command (default: config/env/detected/chroma)",
|
||||
)
|
||||
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
# init
|
||||
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(
|
||||
"--backend",
|
||||
default=None,
|
||||
help="Storage backend to persist for this palace (default: chroma)",
|
||||
)
|
||||
p_init.add_argument(
|
||||
"--yes",
|
||||
action="store_true",
|
||||
@@ -1292,6 +1433,11 @@ def main():
|
||||
# mine
|
||||
p_mine = sub.add_parser("mine", help="Mine files into the palace")
|
||||
p_mine.add_argument("dir", help="Directory to mine")
|
||||
p_mine.add_argument(
|
||||
"--backend",
|
||||
default=None,
|
||||
help="Storage backend to use for this mine (default: config/env/detected/chroma)",
|
||||
)
|
||||
p_mine.add_argument(
|
||||
"--mode",
|
||||
choices=["projects", "convos", "extract"],
|
||||
@@ -1401,6 +1547,11 @@ def main():
|
||||
# search
|
||||
p_search = sub.add_parser("search", help="Find anything, exact words")
|
||||
p_search.add_argument("query", help="What to search for")
|
||||
p_search.add_argument(
|
||||
"--backend",
|
||||
default=None,
|
||||
help="Storage backend to use for this search (default: config/env/detected/chroma)",
|
||||
)
|
||||
p_search.add_argument("--wing", default=None, help="Limit to one project")
|
||||
p_search.add_argument("--room", default=None, help="Limit to one room")
|
||||
p_search.add_argument("--results", type=int, default=5, help="Number of results")
|
||||
@@ -1555,10 +1706,15 @@ def main():
|
||||
)
|
||||
|
||||
# mcp
|
||||
sub.add_parser(
|
||||
p_mcp = sub.add_parser(
|
||||
"mcp",
|
||||
help="Show MCP setup command for connecting MemPalace to your AI client",
|
||||
)
|
||||
p_mcp.add_argument(
|
||||
"--backend",
|
||||
default=None,
|
||||
help="Storage backend to include in the MCP startup command",
|
||||
)
|
||||
|
||||
# status
|
||||
# migrate
|
||||
@@ -1575,9 +1731,52 @@ def main():
|
||||
"--yes", action="store_true", help="Skip confirmation for destructive changes"
|
||||
)
|
||||
|
||||
sub.add_parser("status", help="Show what's been filed")
|
||||
# migrate-wings
|
||||
p_migrate_wings = sub.add_parser(
|
||||
"migrate-wings",
|
||||
help="Normalize legacy wing names (strip leading/trailing separators) so pre-#1675 palaces stay discoverable",
|
||||
)
|
||||
p_migrate_wings.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would change without modifying the palace",
|
||||
)
|
||||
p_migrate_wings.add_argument("--yes", action="store_true", help="Skip the confirmation prompt")
|
||||
|
||||
p_status = sub.add_parser("status", help="Show what's been filed")
|
||||
p_status.add_argument(
|
||||
"--backend",
|
||||
default=None,
|
||||
help="Storage backend to use for status (default: config/env/detected/chroma)",
|
||||
)
|
||||
|
||||
p_palace = sub.add_parser("palace", help="Palace maintenance commands")
|
||||
palace_sub = p_palace.add_subparsers(dest="palace_action")
|
||||
p_set_embedder = palace_sub.add_parser(
|
||||
"set-embedder",
|
||||
help="Record/override the palace's embedder identity (resolve 'unknown', or switch models)",
|
||||
)
|
||||
p_set_embedder.add_argument(
|
||||
"--model",
|
||||
default=None,
|
||||
help="Embedder model to record (default: current configured model). "
|
||||
"Records identity on the palace only; does not change the configured "
|
||||
"model (prints how to align MEMPALACE_EMBEDDING_MODEL if they differ).",
|
||||
)
|
||||
p_set_embedder.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite an existing identity that names a different model "
|
||||
"(only if you know the stored vectors are compatible)",
|
||||
)
|
||||
p_set_embedder.add_argument(
|
||||
"--backend",
|
||||
default=None,
|
||||
help="Storage backend (default: config/env/detected/chroma)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
_apply_backend_arg(args)
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
@@ -1600,6 +1799,13 @@ def main():
|
||||
cmd_instructions(args)
|
||||
return
|
||||
|
||||
if args.command == "palace":
|
||||
if getattr(args, "palace_action", None) == "set-embedder":
|
||||
cmd_palace_set_embedder(args)
|
||||
else:
|
||||
p_palace.print_help()
|
||||
return
|
||||
|
||||
dispatch = {
|
||||
"init": cmd_init,
|
||||
"mine": cmd_mine,
|
||||
@@ -1613,6 +1819,7 @@ def main():
|
||||
"repair": cmd_repair,
|
||||
"repair-status": cmd_repair_status,
|
||||
"migrate": cmd_migrate,
|
||||
"migrate-wings": cmd_migrate_wings,
|
||||
"status": cmd_status,
|
||||
}
|
||||
dispatch[args.command](args)
|
||||
|
||||
@@ -264,7 +264,7 @@ def regenerate_closets(
|
||||
f"Regenerating closets for {len(sources)} source files via {cfg.endpoint} ({cfg.model})..."
|
||||
)
|
||||
if dry_run:
|
||||
print("DRY RUN — no changes will be written")
|
||||
print("DRY RUN - no changes will be written")
|
||||
|
||||
processed = 0
|
||||
failed = 0
|
||||
@@ -286,7 +286,7 @@ def regenerate_closets(
|
||||
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")
|
||||
print(f" [{i}/{len(sources)}] [FAIL] {os.path.basename(source)} - LLM failed")
|
||||
continue
|
||||
|
||||
if usage:
|
||||
@@ -323,7 +323,7 @@ def regenerate_closets(
|
||||
|
||||
processed += 1
|
||||
n_topics = len(parsed.get("topics", []))
|
||||
print(f" [{i}/{len(sources)}] ✓ {os.path.basename(source)} — {n_topics} topics")
|
||||
print(f" [{i}/{len(sources)}] [OK] {os.path.basename(source)} - {n_topics} topics")
|
||||
|
||||
print(f"\nDone. {processed} regenerated, {failed} failed.")
|
||||
if total_input or total_output:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Pre-mining defense against drawer_id collisions.
|
||||
|
||||
Runs immediately before a batched chromadb upsert. Computes the union of
|
||||
incoming drawer_ids and existing drawer_ids that share a key with the
|
||||
batch; raises ``CollisionError`` if any drawer_id appears more than once
|
||||
in that union with conflicting ``(source_file, chunk_index)`` metadata.
|
||||
|
||||
Under the v2 hash recipe (see :mod:`mempalace.ids`) accidental collisions
|
||||
are vanishingly rare — SHA-256 truncated to 24 hex chars makes random
|
||||
collision ~2^-96. The scan exists for two reasons:
|
||||
|
||||
1. Catch upstream bugs that emit duplicate ``(source_file, chunk_index)``
|
||||
pairs in the same batch with conflicting content. ChromaDB would
|
||||
silently let the last-write win; the scan surfaces it as an
|
||||
actionable error naming both call sites.
|
||||
2. Catch the astronomical-but-possible SHA-256 hash collision with a
|
||||
clear message instead of a silent overwrite at upsert time.
|
||||
|
||||
The scan does NOT fire on idempotent re-mines — when an incoming drawer
|
||||
matches an existing one with the SAME ``(source_file, chunk_index)``
|
||||
metadata, that is normal re-write behavior, not collision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class CollisionError(Exception):
|
||||
"""Raised by :func:`assert_no_collisions` when the pre-mining scan
|
||||
detects a drawer_id that would silently overwrite existing content
|
||||
or duplicate within a batch with conflicting metadata.
|
||||
|
||||
The exception message names every colliding ``drawer_id`` and the
|
||||
full set of ``(source_file, chunk_index)`` pairs producing each one,
|
||||
so a user fixing one collision does not have to rediscover the next
|
||||
by re-running the mine.
|
||||
"""
|
||||
|
||||
|
||||
def _metadata_key(meta: dict) -> tuple:
|
||||
"""Reduce a drawer metadata dict to the tuple used for collision
|
||||
discrimination. Two metadata dicts are 'the same chunk' iff their
|
||||
key tuples match. Falls back to ``(source_file,)`` when
|
||||
``chunk_index`` is absent (diary entries, sentinels)."""
|
||||
source_file = meta.get("source_file")
|
||||
chunk_index = meta.get("chunk_index")
|
||||
if chunk_index is None:
|
||||
return (source_file,)
|
||||
return (source_file, chunk_index)
|
||||
|
||||
|
||||
def assert_no_collisions(
|
||||
proposed: list[tuple[str, dict]],
|
||||
collection,
|
||||
) -> None:
|
||||
"""Abort the mine via ``CollisionError`` if any proposed drawer_id
|
||||
collides with itself or with an existing drawer in ``collection``.
|
||||
|
||||
Args:
|
||||
proposed: list of ``(drawer_id, metadata)`` tuples for the
|
||||
chunks about to be upserted. ``metadata`` must carry at
|
||||
least ``source_file``; ``chunk_index`` is used when
|
||||
present.
|
||||
collection: a ChromaDB-shaped collection with ``get(ids=...)``
|
||||
returning a dict with ``ids`` and ``metadatas`` keys.
|
||||
|
||||
Raises:
|
||||
CollisionError: when a drawer_id maps to two or more distinct
|
||||
``(source_file, chunk_index)`` tuples in the union of
|
||||
incoming and existing rows.
|
||||
"""
|
||||
if not proposed:
|
||||
return
|
||||
|
||||
# Build incoming map: drawer_id -> set of metadata key tuples.
|
||||
# Using a set collapses duplicate-metadata cases (same chunk twice
|
||||
# in the batch) without flagging them as collisions.
|
||||
incoming: dict[str, set[tuple]] = defaultdict(set)
|
||||
for drawer_id, meta in proposed:
|
||||
incoming[drawer_id].add(_metadata_key(meta))
|
||||
|
||||
# Query existing rows for any incoming id. ChromaDB's get(ids=...)
|
||||
# returns only the rows whose ids are present; missing ids are
|
||||
# silently absent from the result, which is what we want.
|
||||
incoming_ids = list(incoming.keys())
|
||||
result = collection.get(ids=incoming_ids, include=["metadatas"])
|
||||
existing_ids: list = result["ids"] if hasattr(result, "__getitem__") else []
|
||||
existing_metas: list = result["metadatas"] if existing_ids else []
|
||||
|
||||
# Merge existing metadata into the incoming map. A real collision is
|
||||
# a drawer_id whose incoming + existing metadata key tuples are not
|
||||
# all the same.
|
||||
for drawer_id, meta in zip(existing_ids, existing_metas):
|
||||
incoming[drawer_id].add(_metadata_key(meta or {}))
|
||||
|
||||
collisions = {did: keys for did, keys in incoming.items() if len(keys) > 1}
|
||||
if collisions:
|
||||
raise CollisionError(_format_collisions(collisions))
|
||||
|
||||
|
||||
def _format_collisions(collisions: dict[str, set[tuple]]) -> str:
|
||||
"""Render a CollisionError message that enumerates every colliding
|
||||
drawer_id and the metadata tuples producing it."""
|
||||
lines = [
|
||||
f"Pre-mining collision scan detected {len(collisions)} "
|
||||
f"colliding drawer_id{'s' if len(collisions) != 1 else ''}:",
|
||||
]
|
||||
for drawer_id, keys in sorted(collisions.items()):
|
||||
lines.append(f" {drawer_id}:")
|
||||
for key in sorted(keys, key=lambda k: tuple(str(part) for part in k)):
|
||||
if len(key) == 1:
|
||||
lines.append(f" source_file={key[0]!r}")
|
||||
else:
|
||||
lines.append(f" source_file={key[0]!r}, chunk_index={key[1]!r}")
|
||||
lines.append(
|
||||
"Each colliding drawer_id would cause the second ChromaDB upsert "
|
||||
"to silently overwrite the first. Fix the upstream chunker / "
|
||||
"miner to emit distinct keys, or investigate the SHA-256 hash "
|
||||
"collision."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
+154
-1
@@ -37,8 +37,13 @@ def normalize_wing_name(name: str) -> str:
|
||||
The same rule is applied by ``init`` when persisting `topics_by_wing`
|
||||
and when writing `mempalace.yaml`, so the miner's lookup matches at
|
||||
mine time regardless of the source dirname.
|
||||
|
||||
Leading/trailing separators are stripped so a path-encoded dirname like
|
||||
``-home-user-proj`` yields ``home_user_proj`` rather than a leading-
|
||||
underscore slug that ``sanitize_name`` (and thus the MCP write tools)
|
||||
would reject.
|
||||
"""
|
||||
return name.lower().replace(" ", "_").replace("-", "_")
|
||||
return name.lower().replace(" ", "_").replace("-", "_").strip("_")
|
||||
|
||||
|
||||
def sanitize_name(value: str, field_name: str = "name") -> str:
|
||||
@@ -191,6 +196,13 @@ def sanitize_content(value: str, max_length: int = 100_000) -> str:
|
||||
|
||||
DEFAULT_PALACE_PATH = os.path.expanduser("~/.mempalace/palace")
|
||||
DEFAULT_COLLECTION_NAME = "mempalace_drawers"
|
||||
DEFAULT_BACKEND = "chroma"
|
||||
|
||||
# How many timestamped palace backups to retain before the oldest are
|
||||
# pruned. Applies to the accumulating backups written by ``mempalace
|
||||
# migrate`` and ``mempalace repair max-seq-id`` — see
|
||||
# ``MempalaceConfig.max_backups``.
|
||||
DEFAULT_MAX_BACKUPS = 10
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -320,11 +332,104 @@ class MempalaceConfig:
|
||||
"""Path to the tunnel file, sibling of palace_path."""
|
||||
return os.path.join(os.path.dirname(self.palace_path), "tunnels.json")
|
||||
|
||||
@property
|
||||
def hallway_file(self):
|
||||
"""Path to the hallway file, sibling of palace_path.
|
||||
|
||||
Mirrors ``tunnel_file`` so within-wing hallway state is scoped to the
|
||||
configured palace and survives palace rebuilds (it does not live in
|
||||
ChromaDB which can be recreated). Prior to this property the path was
|
||||
hardcoded under ``~/.mempalace/hallways.json`` and multiple palaces on
|
||||
one host silently shared one file (see ``hallways._legacy_hallway_file``).
|
||||
"""
|
||||
return os.path.join(os.path.dirname(self.palace_path), "hallways.json")
|
||||
|
||||
@property
|
||||
def collection_name(self):
|
||||
"""ChromaDB collection name."""
|
||||
return self._file_config.get("collection_name", DEFAULT_COLLECTION_NAME)
|
||||
|
||||
@property
|
||||
def backend(self):
|
||||
"""Storage backend name.
|
||||
|
||||
Read from ``config.json`` first, then ``MEMPALACE_BACKEND``, then
|
||||
``"chroma"`` for backwards compatibility with existing palaces.
|
||||
"""
|
||||
cfg_val = self._file_config.get("backend")
|
||||
if cfg_val:
|
||||
return str(cfg_val).strip().lower()
|
||||
env_val = os.environ.get("MEMPALACE_BACKEND")
|
||||
if env_val:
|
||||
return env_val.strip().lower()
|
||||
return DEFAULT_BACKEND
|
||||
|
||||
@property
|
||||
def qdrant_url(self):
|
||||
"""Qdrant endpoint for the opt-in ``qdrant`` backend.
|
||||
|
||||
Defaults to localhost so selecting Qdrant never silently sends memory
|
||||
to a remote service. Users can point at a LAN or cloud endpoint via
|
||||
config or ``MEMPALACE_QDRANT_URL`` when they deliberately choose that.
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_QDRANT_URL")
|
||||
if env_val:
|
||||
return env_val.strip()
|
||||
return str(self._file_config.get("qdrant_url", "http://localhost:6333")).strip()
|
||||
|
||||
@property
|
||||
def qdrant_api_key(self):
|
||||
"""API key for the opt-in ``qdrant`` backend, if configured."""
|
||||
env_val = os.environ.get("MEMPALACE_QDRANT_API_KEY")
|
||||
if env_val:
|
||||
return env_val
|
||||
value = self._file_config.get("qdrant_api_key")
|
||||
return str(value) if value else None
|
||||
|
||||
@property
|
||||
def qdrant_namespace(self):
|
||||
"""Optional Qdrant collection namespace/prefix."""
|
||||
env_val = os.environ.get("MEMPALACE_QDRANT_NAMESPACE")
|
||||
if env_val:
|
||||
return env_val.strip()
|
||||
value = self._file_config.get("qdrant_namespace")
|
||||
return str(value).strip() if value else None
|
||||
|
||||
@property
|
||||
def qdrant_timeout(self):
|
||||
"""Qdrant HTTP timeout in seconds."""
|
||||
env_val = os.environ.get("MEMPALACE_QDRANT_TIMEOUT")
|
||||
raw = env_val if env_val is not None else self._file_config.get("qdrant_timeout", 10.0)
|
||||
try:
|
||||
timeout = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 10.0
|
||||
return timeout if timeout > 0 else 10.0
|
||||
|
||||
@property
|
||||
def pgvector_dsn(self):
|
||||
"""Postgres DSN for the opt-in ``pgvector`` backend.
|
||||
|
||||
Defaults to a localhost DSN so selecting pgvector never silently sends
|
||||
memory to a remote database. Point at a LAN or cloud Postgres via config
|
||||
or ``MEMPALACE_PGVECTOR_DSN`` only when deliberately chosen.
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_PGVECTOR_DSN")
|
||||
if env_val:
|
||||
return env_val.strip()
|
||||
return str(
|
||||
self._file_config.get("pgvector_dsn", "postgresql://localhost:5432/mempalace")
|
||||
).strip()
|
||||
|
||||
@property
|
||||
def pgvector_namespace(self):
|
||||
"""Optional pgvector table namespace/prefix for multi-tenant isolation."""
|
||||
env_val = os.environ.get("MEMPALACE_PGVECTOR_NAMESPACE")
|
||||
if env_val:
|
||||
return env_val.strip()
|
||||
value = self._file_config.get("pgvector_namespace")
|
||||
return str(value).strip() if value else None
|
||||
|
||||
@property
|
||||
def people_map(self):
|
||||
"""Mapping of name variants to canonical names."""
|
||||
@@ -560,6 +665,24 @@ class MempalaceConfig:
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
def set_backend(self, backend: str) -> None:
|
||||
"""Persist the storage backend choice to ``config.json``."""
|
||||
backend = str(backend).strip().lower()
|
||||
from .backends import get_backend_class
|
||||
|
||||
get_backend_class(backend)
|
||||
self._file_config["backend"] = backend
|
||||
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
|
||||
|
||||
@property
|
||||
def topic_tunnel_min_count(self):
|
||||
"""Minimum number of overlapping confirmed topics required to create
|
||||
@@ -586,6 +709,36 @@ class MempalaceConfig:
|
||||
parsed = 1
|
||||
return max(1, parsed)
|
||||
|
||||
@property
|
||||
def max_backups(self) -> int:
|
||||
"""Number of timestamped palace backups to retain before pruning.
|
||||
|
||||
Applies to the accumulating, timestamped backups created by
|
||||
``mempalace migrate`` (``<palace>.pre-migrate.<timestamp>``) and
|
||||
``mempalace repair max-seq-id``
|
||||
(``chroma.sqlite3.max-seq-id-backup-<timestamp>``). Each of those
|
||||
commands writes a fresh full-size copy every run and historically
|
||||
never deleted the old ones, so on a machine that mines or repairs on
|
||||
a schedule the backup set could silently grow until it filled the
|
||||
disk. After each backup is written, copies beyond this count (oldest
|
||||
first) are removed.
|
||||
|
||||
Reads ``MEMPALACE_MAX_BACKUPS`` env first, then ``max_backups`` in
|
||||
``config.json``, then the default of ``10``. A value of ``0`` disables
|
||||
pruning and keeps every backup (use when an external retention policy
|
||||
manages cleanup). Negative or non-numeric values fall back to the
|
||||
default rather than crashing migrate/repair.
|
||||
"""
|
||||
env_val = os.environ.get("MEMPALACE_MAX_BACKUPS")
|
||||
if env_val is not None:
|
||||
coerced = self._try_coerce_int(env_val, minimum=0)
|
||||
if coerced is not None:
|
||||
return coerced
|
||||
coerced = self._try_coerce_int(
|
||||
self._file_config.get("max_backups", DEFAULT_MAX_BACKUPS), minimum=0
|
||||
)
|
||||
return DEFAULT_MAX_BACKUPS if coerced is None else coerced
|
||||
|
||||
@property
|
||||
def hook_silent_save(self):
|
||||
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
|
||||
|
||||
+27
-14
@@ -10,13 +10,14 @@ Same palace as project mining. Different ingest strategy.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from .collision_scan import assert_no_collisions
|
||||
from .ids import ID_RECIPE, make_convo_drawer_id, make_convo_sentinel_id
|
||||
from .normalize import normalize
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
@@ -83,8 +84,7 @@ def _register_file(collection, source_file: str, wing: str, agent: str, extract_
|
||||
re-read and re-processed on every mine run because nothing was written to
|
||||
ChromaDB on the first pass.
|
||||
"""
|
||||
sentinel_key = f"{source_file}:{extract_mode}"
|
||||
sentinel_id = f"_reg_{hashlib.sha256(sentinel_key.encode()).hexdigest()[:24]}"
|
||||
sentinel_id = make_convo_sentinel_id(source_file, extract_mode)
|
||||
collection.upsert(
|
||||
documents=[f"[registry] {source_file}"],
|
||||
ids=[sentinel_id],
|
||||
@@ -98,6 +98,7 @@ def _register_file(collection, source_file: str, wing: str, agent: str, extract_
|
||||
"ingest_mode": "registry",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -192,11 +193,15 @@ def _chunk_by_exchange(lines: list, chunk_size: int, min_chunk_size: int) -> lis
|
||||
next_line = lines[i]
|
||||
if next_line.strip().startswith(">") or next_line.strip().startswith("---"):
|
||||
break
|
||||
if next_line.strip():
|
||||
ai_lines.append(next_line.strip())
|
||||
# Preserve the line as-is — blank lines and indentation carry meaning
|
||||
# (paragraph breaks, list/code structure) and must survive verbatim.
|
||||
ai_lines.append(next_line)
|
||||
i += 1
|
||||
|
||||
ai_response = " ".join(ai_lines)
|
||||
# Join on newline (not space) so line structure, blank lines, and
|
||||
# indentation reach the drawer unchanged. Trim only trailing blank
|
||||
# lines produced by the loop stopping at the next `>` turn.
|
||||
ai_response = "\n".join(ai_lines).rstrip("\n")
|
||||
content = f"{user_turn}\n{ai_response}" if ai_response else user_turn
|
||||
|
||||
_emit_bounded(chunks, content, chunk_size, min_chunk_size)
|
||||
@@ -419,10 +424,8 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
|
||||
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
|
||||
if extract_mode == "general":
|
||||
room_counts_delta[chunk_room] += 1
|
||||
drawer_key = f"{source_file}:{extract_mode}:{chunk['chunk_index']}"
|
||||
drawer_id = (
|
||||
f"drawer_{wing}_{chunk_room}_"
|
||||
f"{hashlib.sha256(drawer_key.encode()).hexdigest()[:24]}"
|
||||
drawer_id = make_convo_drawer_id(
|
||||
wing, chunk_room, source_file, extract_mode, chunk["chunk_index"]
|
||||
)
|
||||
batch_docs.append(chunk["content"])
|
||||
batch_ids.append(drawer_id)
|
||||
@@ -438,8 +441,10 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
|
||||
"ingest_mode": "convos",
|
||||
"extract_mode": extract_mode,
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
)
|
||||
assert_no_collisions(list(zip(batch_ids, batch_metas)), collection)
|
||||
try:
|
||||
collection.upsert(
|
||||
documents=batch_docs,
|
||||
@@ -589,15 +594,14 @@ def _mine_convos_impl(
|
||||
wing = _resolve_wing(convo_path, wing)
|
||||
|
||||
files = scan_convos(convo_dir)
|
||||
if limit > 0:
|
||||
files = files[:limit]
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Mine — Conversations")
|
||||
print(f"{'=' * 55}")
|
||||
print(f" Wing: {wing}")
|
||||
print(f" Source: {convo_path}")
|
||||
print(f" Files: {len(files)}")
|
||||
limit_suffix = f" (limit: {limit} new)" if limit > 0 else ""
|
||||
print(f" Files: {len(files)}{limit_suffix}")
|
||||
print(f" Palace: {palace_path}")
|
||||
if dry_run:
|
||||
print(" DRY RUN — nothing will be filed")
|
||||
@@ -615,10 +619,13 @@ def _mine_convos_impl(
|
||||
)
|
||||
|
||||
total_drawers = 0
|
||||
files_mined = 0
|
||||
files_skipped = 0
|
||||
files_processed = 0
|
||||
room_counts = defaultdict(int)
|
||||
|
||||
for i, filepath in enumerate(files, 1):
|
||||
files_processed = i
|
||||
source_file = str(filepath)
|
||||
|
||||
# Skip if already filed at current NORMALIZE_VERSION
|
||||
@@ -679,6 +686,9 @@ def _mine_convos_impl(
|
||||
room_counts[c.get("memory_type", "general")] += 1
|
||||
else:
|
||||
room_counts[room] += 1
|
||||
files_mined += 1
|
||||
if limit > 0 and files_mined >= limit:
|
||||
break
|
||||
continue
|
||||
|
||||
if extract_mode != "general":
|
||||
@@ -696,14 +706,17 @@ def _mine_convos_impl(
|
||||
room_counts[r] += n
|
||||
|
||||
total_drawers += drawers_added
|
||||
files_mined += 1
|
||||
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
|
||||
if limit > 0 and files_mined >= limit:
|
||||
break
|
||||
|
||||
if not dry_run:
|
||||
_validate_palace_fts5_after_mine(palace_path)
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" Done.")
|
||||
print(f" Files processed: {len(files) - files_skipped}")
|
||||
print(f" Files processed: {files_processed - files_skipped}")
|
||||
print(f" Files skipped (already filed): {files_skipped}")
|
||||
print(f" Drawers filed: {total_drawers}")
|
||||
if room_counts:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"description": "Multi-word product/system names that must be detected atomically — not decomposed into their constituent words. When any of these compounds appears in mined content, the entity detector counts the COMPOUND, not the parts. The single-word detection pass that runs after this compound pre-pass would otherwise decompose 'Claude Code' into 'Claude' + 'Code', and the COCA filter (Tier 2) would then drop 'Code' as a content word while attributing usage incorrectly to 'Claude' alone.",
|
||||
"source": "Curated 2026-05-24 from common AI/development product names that mempalace users frequently mention. Each entry is a multi-word name (two-or-more whitespace-separated tokens, or hyphenated). Single-word product names like 'ChatGPT', 'Cursor', 'OrbStack' are NOT included — they have no decomposition risk and are handled by the existing single-word regex.",
|
||||
"case_handling": "Matching is case-insensitive. Canonical form (the entry below) is what gets counted. Caller must use re.IGNORECASE when matching.",
|
||||
"tier_2_interaction": "This file complements coca_content_words.json. Tier 2 filters single-word content nouns. Tier 3 protects multi-word product names. Run THIS pre-pass FIRST so the COCA filter doesn't see the decomposed words.",
|
||||
"compounds": [
|
||||
"Claude Code",
|
||||
"Claude Desktop",
|
||||
"Claude Sonnet",
|
||||
"Claude Opus",
|
||||
"Claude Haiku",
|
||||
"Claude Sonnet 4.5",
|
||||
"Claude Sonnet 4.6",
|
||||
"Claude Opus 4.5",
|
||||
"Claude Opus 4.6",
|
||||
"Claude Opus 4.7",
|
||||
"Gemini Code Assist",
|
||||
"Gemini 1.5 Pro",
|
||||
"Gemini 2.5 Pro",
|
||||
"Gemini 2.5 Flash",
|
||||
"Gemini Pro",
|
||||
"Gemini Flash",
|
||||
"GitHub Copilot",
|
||||
"GitHub Copilot CLI",
|
||||
"Microsoft Copilot",
|
||||
"Visual Studio Code",
|
||||
"Visual Studio",
|
||||
"IntelliJ IDEA",
|
||||
"Android Studio",
|
||||
"Sublime Text",
|
||||
"Replit Agent",
|
||||
"Docker Desktop",
|
||||
"Docker Compose",
|
||||
"GitHub Actions",
|
||||
"GitHub Pages",
|
||||
"GitHub Codespaces",
|
||||
"GitHub Pull Request",
|
||||
"Hugging Face",
|
||||
"Stack Overflow",
|
||||
"Stack Exchange",
|
||||
"Hacker News",
|
||||
"Y Combinator",
|
||||
"Google Cloud Platform",
|
||||
"Amazon Web Services",
|
||||
"Microsoft Azure",
|
||||
"Azure DevOps",
|
||||
"Notion AI",
|
||||
"OpenAI API",
|
||||
"Anthropic Console",
|
||||
"GPT-4",
|
||||
"GPT-4o",
|
||||
"GPT-5",
|
||||
"Llama 3",
|
||||
"Llama 3.1",
|
||||
"Mistral Small",
|
||||
"Mistral Large",
|
||||
"Microsoft Word",
|
||||
"Microsoft Excel",
|
||||
"Microsoft PowerPoint",
|
||||
"Google Docs",
|
||||
"Google Sheets",
|
||||
"Google Drive",
|
||||
"Google Cloud",
|
||||
"Apple Watch",
|
||||
"Apple TV"
|
||||
]
|
||||
}
|
||||
+6
-4
@@ -7,7 +7,9 @@ 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.
|
||||
Uses the configured storage backend's similarity search. With the default
|
||||
local backends (Chroma, sqlite_exact) this stays on-machine with no external
|
||||
calls; with a remote backend (e.g. Qdrant) it issues queries to that backend.
|
||||
|
||||
Usage (standalone):
|
||||
python -m mempalace.dedup # dedup all
|
||||
@@ -27,7 +29,7 @@ import os
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from .backends.chroma import ChromaBackend
|
||||
from .palace import get_collection
|
||||
|
||||
|
||||
COLLECTION_NAME = "mempalace_drawers"
|
||||
@@ -130,7 +132,7 @@ def dedup_source_group(col, drawer_ids, threshold=DEFAULT_THRESHOLD, dry_run=Tru
|
||||
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)
|
||||
col = get_collection(palace_path, COLLECTION_NAME)
|
||||
|
||||
groups = get_source_groups(col)
|
||||
|
||||
@@ -162,7 +164,7 @@ def dedup_palace(
|
||||
print(" MemPalace Deduplicator")
|
||||
print(f"{'=' * 55}")
|
||||
|
||||
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
|
||||
col = get_collection(palace_path, COLLECTION_NAME)
|
||||
|
||||
print(f" Palace: {palace_path}")
|
||||
print(f" Drawers: {col.count():,}")
|
||||
|
||||
+173
-55
@@ -32,6 +32,7 @@ rather than hard-failing — mining must still work on a laptop without CUDA.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,6 +57,10 @@ _AUTO_ORDER = [
|
||||
]
|
||||
|
||||
_EF_CACHE: dict = {}
|
||||
# Check-then-construct on the cache must be atomic: without it, two threads
|
||||
# resolving the same key each keep their own EF instance, and each instance
|
||||
# later lazy-loads its own copy of the model.
|
||||
_EF_CACHE_LOCK = threading.Lock()
|
||||
_WARNED: set = set()
|
||||
|
||||
|
||||
@@ -135,6 +140,16 @@ _EMBEDDINGGEMMA_ONNX = "model_quantized.onnx"
|
||||
_EMBEDDINGGEMMA_PREFIX = "task: sentence similarity | query: "
|
||||
_EMBEDDINGGEMMA_DIM = 384 # Matryoshka truncation — first 384 dims of the 768
|
||||
_EMBEDDINGGEMMA_MAX_LEN = 2048
|
||||
# Default docs per session.run. The ONNX graph has no internal batching,
|
||||
# so one unchunked run over a repair-scale batch (5000 docs, repair.py/
|
||||
# cli.py) allocates attention buffers that grow with batch size and
|
||||
# superlinearly with padded length (score tensors are batch x heads x
|
||||
# len^2 per layer), and the kernel OOM-kills the process (#1770). 32
|
||||
# matches the internal batch size of chromadb's ONNXMiniLM_L6_V2, whose
|
||||
# chunked _forward survives the same call sites. embeddinggemma's
|
||||
# sentence_embedding output is attention-masked, so sub-batch padding
|
||||
# does not change any row's vector.
|
||||
_EMBEDDINGGEMMA_BATCH_SIZE = 32
|
||||
|
||||
|
||||
class EmbeddinggemmaONNX:
|
||||
@@ -158,70 +173,113 @@ class EmbeddinggemmaONNX:
|
||||
# when switching models. Keep it stable.
|
||||
return "embeddinggemma_300m"
|
||||
|
||||
def __init__(self, preferred_providers=None):
|
||||
def __init__(self, preferred_providers=None, batch_size: int = _EMBEDDINGGEMMA_BATCH_SIZE):
|
||||
if batch_size < 1:
|
||||
raise ValueError(f"batch_size must be >= 1, got {batch_size}")
|
||||
self._providers = (
|
||||
list(preferred_providers) if preferred_providers else ["CPUExecutionProvider"]
|
||||
)
|
||||
self._batch_size = batch_size
|
||||
self._session = None
|
||||
self._tokenizer = None
|
||||
self._np = None
|
||||
self._output_idx = None
|
||||
# Instances are shared across threads via _EF_CACHE; serialize the
|
||||
# one-time model load so concurrent cold calls cannot build (and
|
||||
# transiently hold) two full model sessions.
|
||||
self._load_lock = threading.Lock()
|
||||
|
||||
def _lazy_load(self) -> None:
|
||||
if self._session is not None:
|
||||
return
|
||||
try:
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from huggingface_hub import hf_hub_download
|
||||
from tokenizers import Tokenizer
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"EmbeddinggemmaONNX requires huggingface_hub, tokenizers, and "
|
||||
"numpy — these ship with mempalace core, so this error usually "
|
||||
"means one was uninstalled or pinned to an incompatible version. "
|
||||
"Reinstall with: pip install --upgrade --force-reinstall mempalace"
|
||||
) from e
|
||||
with self._load_lock:
|
||||
if self._session is not None:
|
||||
return
|
||||
try:
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from huggingface_hub import hf_hub_download
|
||||
from tokenizers import Tokenizer
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"EmbeddinggemmaONNX requires huggingface_hub, tokenizers, and "
|
||||
"numpy — these ship with mempalace core, so this error usually "
|
||||
"means one was uninstalled or pinned to an incompatible version. "
|
||||
"Reinstall with: pip install --upgrade --force-reinstall mempalace"
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"Downloading %s/%s (cached after first run)…",
|
||||
_EMBEDDINGGEMMA_REPO,
|
||||
_EMBEDDINGGEMMA_ONNX,
|
||||
)
|
||||
model_path = hf_hub_download(
|
||||
_EMBEDDINGGEMMA_REPO, subfolder="onnx", filename=_EMBEDDINGGEMMA_ONNX
|
||||
)
|
||||
tok_path = hf_hub_download(_EMBEDDINGGEMMA_REPO, filename="tokenizer.json")
|
||||
logger.info(
|
||||
"Downloading %s/%s (cached after first run)…",
|
||||
_EMBEDDINGGEMMA_REPO,
|
||||
_EMBEDDINGGEMMA_ONNX,
|
||||
)
|
||||
model_path = hf_hub_download(
|
||||
_EMBEDDINGGEMMA_REPO, subfolder="onnx", filename=_EMBEDDINGGEMMA_ONNX
|
||||
)
|
||||
hf_hub_download(
|
||||
_EMBEDDINGGEMMA_REPO, subfolder="onnx", filename=_EMBEDDINGGEMMA_ONNX + "_data"
|
||||
)
|
||||
tok_path = hf_hub_download(_EMBEDDINGGEMMA_REPO, filename="tokenizer.json")
|
||||
|
||||
self._session = ort.InferenceSession(model_path, providers=self._providers)
|
||||
out_names = [o.name for o in self._session.get_outputs()]
|
||||
# Model card: sentence_embedding is the pooled output (last_hidden_state
|
||||
# is the per-token output we don't want).
|
||||
self._output_idx = (
|
||||
out_names.index("sentence_embedding") if "sentence_embedding" in out_names else 1
|
||||
)
|
||||
session = ort.InferenceSession(model_path, providers=self._providers)
|
||||
out_names = [o.name for o in session.get_outputs()]
|
||||
# Model card: sentence_embedding is the pooled output (last_hidden_state
|
||||
# is the per-token output we don't want).
|
||||
output_idx = (
|
||||
out_names.index("sentence_embedding") if "sentence_embedding" in out_names else 1
|
||||
)
|
||||
|
||||
tokenizer = Tokenizer.from_file(tok_path)
|
||||
tokenizer.enable_padding()
|
||||
tokenizer.enable_truncation(max_length=_EMBEDDINGGEMMA_MAX_LEN)
|
||||
self._tokenizer = tokenizer
|
||||
self._np = np
|
||||
tokenizer = Tokenizer.from_file(tok_path)
|
||||
tokenizer.enable_padding()
|
||||
tokenizer.enable_truncation(max_length=_EMBEDDINGGEMMA_MAX_LEN)
|
||||
self._output_idx = output_idx
|
||||
self._tokenizer = tokenizer
|
||||
self._np = np
|
||||
# Session is assigned last: the unlocked fast path above treats a
|
||||
# non-None session as "fully loaded", so every other attribute
|
||||
# must already be in place when it becomes visible.
|
||||
self._session = session
|
||||
|
||||
def __call__(self, input): # noqa: A002 — ChromaDB EF protocol uses `input`
|
||||
def __call__(self, input: str | list[str] | None) -> list[list[float]]: # noqa: A002 — ChromaDB EF protocol
|
||||
if isinstance(input, str):
|
||||
# A bare string would be iterated character by character below,
|
||||
# silently producing one garbage vector per character.
|
||||
input = [input]
|
||||
if input is None or len(input) == 0:
|
||||
# None or zero docs: nothing to embed; skip the lazy model
|
||||
# download. len() over truthiness so an array-like documents
|
||||
# sequence is not rejected by ambiguous-truth-value semantics.
|
||||
return []
|
||||
self._lazy_load()
|
||||
np = self._np
|
||||
texts = [_EMBEDDINGGEMMA_PREFIX + t for t in input]
|
||||
encs = self._tokenizer.encode_batch(texts)
|
||||
input_ids = np.asarray([e.ids for e in encs], dtype=np.int64)
|
||||
attention_mask = np.asarray([e.attention_mask for e in encs], dtype=np.int64)
|
||||
outputs = self._session.run(
|
||||
None, {"input_ids": input_ids, "attention_mask": attention_mask}
|
||||
)
|
||||
sent_emb = outputs[self._output_idx][:, :_EMBEDDINGGEMMA_DIM]
|
||||
# L2-normalize so cosine similarity == dot product (matches what the
|
||||
# MTEB methodology assumes; ChromaDB's distance is configured for it).
|
||||
norms = np.linalg.norm(sent_emb, axis=1, keepdims=True) + 1e-12
|
||||
return (sent_emb / norms).tolist()
|
||||
embeddings: list[list[float]] = []
|
||||
# Tokenize and run per sub-batch, not over the whole input: padding
|
||||
# is to the longest sequence in the sub-batch, and the ONNX runtime
|
||||
# only ever holds batch_size rows of attention buffers at a time
|
||||
# (#1770).
|
||||
for start in range(0, len(input), self._batch_size):
|
||||
chunk = input[start : start + self._batch_size]
|
||||
texts = [_EMBEDDINGGEMMA_PREFIX + t for t in chunk]
|
||||
encs = self._tokenizer.encode_batch(texts)
|
||||
input_ids = np.asarray([e.ids for e in encs], dtype=np.int64)
|
||||
attention_mask = np.asarray([e.attention_mask for e in encs], dtype=np.int64)
|
||||
outputs = self._session.run(
|
||||
None, {"input_ids": input_ids, "attention_mask": attention_mask}
|
||||
)
|
||||
sent_emb = outputs[self._output_idx][:, :_EMBEDDINGGEMMA_DIM]
|
||||
# L2-normalize so cosine similarity == dot product (matches what the
|
||||
# MTEB methodology assumes; ChromaDB's distance is configured for it).
|
||||
norms = np.linalg.norm(sent_emb, axis=1, keepdims=True) + 1e-12
|
||||
embeddings.extend((sent_emb / norms).tolist())
|
||||
return embeddings
|
||||
|
||||
def embed_query(self, input: list[str]) -> list[list[float]]: # noqa: A002 — ChromaDB EF protocol
|
||||
"""Embed query documents (ChromaDB EF protocol)."""
|
||||
return self(input)
|
||||
|
||||
def embed_documents(self, input: list[str]) -> list[list[float]]: # noqa: A002
|
||||
"""Embed a batch of documents (ChromaDB EF protocol)."""
|
||||
return self(input)
|
||||
|
||||
|
||||
def get_embedding_function(device: Optional[str] = None, model: Optional[str] = None):
|
||||
@@ -243,18 +301,22 @@ def get_embedding_function(device: Optional[str] = None, model: Optional[str] =
|
||||
|
||||
providers, effective = _resolve_providers(device)
|
||||
cache_key = (model, tuple(providers))
|
||||
cached = _EF_CACHE.get(cache_key)
|
||||
cached = _EF_CACHE.get(cache_key) # lock-free fast path; dict.get is GIL-atomic
|
||||
if cached is not None:
|
||||
return cached
|
||||
with _EF_CACHE_LOCK:
|
||||
cached = _EF_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if model == "embeddinggemma":
|
||||
ef = EmbeddinggemmaONNX(preferred_providers=providers)
|
||||
else:
|
||||
# Default: minilm (or anything we don't recognize — back-compat win).
|
||||
ef_cls = _build_ef_class()
|
||||
ef = ef_cls(preferred_providers=providers)
|
||||
if model == "embeddinggemma":
|
||||
ef = EmbeddinggemmaONNX(preferred_providers=providers)
|
||||
else:
|
||||
# Default: minilm (or anything we don't recognize — back-compat win).
|
||||
ef_cls = _build_ef_class()
|
||||
ef = ef_cls(preferred_providers=providers)
|
||||
|
||||
_EF_CACHE[cache_key] = ef
|
||||
_EF_CACHE[cache_key] = ef
|
||||
logger.info(
|
||||
"Embedding function initialized (model=%s device=%s providers=%s)",
|
||||
model,
|
||||
@@ -276,3 +338,59 @@ def describe_device(device: Optional[str] = None) -> str:
|
||||
device = MempalaceConfig().embedding_device
|
||||
_, effective = _resolve_providers(device)
|
||||
return effective
|
||||
|
||||
|
||||
# Probed vector widths, keyed by resolved model name. Populated once per
|
||||
# process the first time an identity is resolved for a model.
|
||||
_DIM_CACHE: dict = {}
|
||||
|
||||
|
||||
def current_model_name(model: Optional[str] = None) -> str:
|
||||
"""Resolve the canonical embedder model name (cheap, no model load).
|
||||
|
||||
This is the configured ``embedding_model`` (``"minilm"`` /
|
||||
``"embeddinggemma"`` / ...), not the embedding function's internal
|
||||
``name()`` (which is spoofed to ``"default"`` for ChromaDB compatibility).
|
||||
"""
|
||||
if model is not None:
|
||||
return str(model).strip().lower()
|
||||
from .config import MempalaceConfig
|
||||
|
||||
return MempalaceConfig().embedding_model
|
||||
|
||||
|
||||
def probe_dimension(device: Optional[str] = None, model: Optional[str] = None) -> int:
|
||||
"""Return the embedder's output dimension by embedding a short probe.
|
||||
|
||||
Model-agnostic — works for any model without a hardcoded table — and
|
||||
cached per resolved model name so the probe is paid at most once per
|
||||
process. Returns ``0`` if the probe fails (treated as "dimension unknown"
|
||||
by the identity check, so a probe failure never blocks normal operation).
|
||||
"""
|
||||
name = current_model_name(model)
|
||||
cached = _DIM_CACHE.get(name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
ef = get_embedding_function(device=device, model=model)
|
||||
vectors = ef(input=["probe"])
|
||||
dim = len(vectors[0]) if vectors and vectors[0] is not None else 0
|
||||
except Exception:
|
||||
logger.debug("Embedding dimension probe failed for model=%s", name, exc_info=True)
|
||||
dim = 0
|
||||
_DIM_CACHE[name] = dim
|
||||
return dim
|
||||
|
||||
|
||||
def get_embedder_identity(device: Optional[str] = None, model: Optional[str] = None):
|
||||
"""Resolve the current embedder identity (RFC 001).
|
||||
|
||||
``model_name`` from config (cheap); ``dimension`` from a cached one-time
|
||||
probe. Returns an :class:`~mempalace.backends.base.EmbedderIdentity`.
|
||||
"""
|
||||
from .backends.base import EmbedderIdentity
|
||||
|
||||
return EmbedderIdentity(
|
||||
model_name=current_model_name(model),
|
||||
dimension=probe_dimension(device=device, model=model),
|
||||
)
|
||||
|
||||
@@ -78,6 +78,98 @@ def _get_coca_filter() -> frozenset[str]:
|
||||
return frozenset()
|
||||
|
||||
|
||||
# ==================== KNOWN-SYSTEMS COMPOUND LEXICON (Tier 3 linguistics cleanup) ====================
|
||||
#
|
||||
# Multi-word product / system names that must be detected atomically — NOT
|
||||
# decomposed into their constituent words. When "Claude Code" appears in
|
||||
# content, the entity detector counts the compound, not the parts. Without
|
||||
# this pre-pass, the single-word loop would split "Claude Code" into
|
||||
# "Claude" + "Code", and the COCA filter (Tier 2) would drop "Code" as a
|
||||
# content word — leaving "Claude" alone with the wrong attribution.
|
||||
#
|
||||
# Data file: ``mempalace/data/known_systems.json``. Loaded once on first
|
||||
# call via ``_get_known_systems``. Matching is case-insensitive with word
|
||||
# boundaries.
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _get_known_systems() -> tuple[tuple[str, "re.Pattern[str]"], ...]:
|
||||
"""Return the known-systems compound tuple — pairs of (canonical name,
|
||||
pre-compiled case-insensitive word-bounded regex).
|
||||
|
||||
Loads ``mempalace/data/known_systems.json`` on first call, compiles a
|
||||
regex for each valid compound, and caches the resulting tuple of
|
||||
pairs. Subsequent calls are O(1) and skip both the disk read AND the
|
||||
regex compilation. Returns an empty tuple if the data file is missing
|
||||
or malformed — extraction behavior then degrades gracefully
|
||||
(compounds detected only by the existing multi-word regex) rather
|
||||
than crashing.
|
||||
|
||||
Entries are sorted by length descending so the compound matcher
|
||||
prefers longer matches first (e.g. "Visual Studio Code" wins over
|
||||
a hypothetical "Visual Studio" if both were in the lexicon).
|
||||
"""
|
||||
data_path = Path(__file__).parent / "data" / "known_systems.json"
|
||||
try:
|
||||
raw = json.loads(data_path.read_text(encoding="utf-8"))
|
||||
compounds = raw.get("compounds", [])
|
||||
valid = [c for c in compounds if isinstance(c, str) and c.strip()]
|
||||
# Sort by length descending so longest-match-wins during the
|
||||
# pre-pass scan (longer compounds get masked first, so a shorter
|
||||
# compound contained within a longer one doesn't double-count).
|
||||
sorted_compounds = sorted(valid, key=len, reverse=True)
|
||||
|
||||
compiled: list[tuple[str, re.Pattern[str]]] = []
|
||||
for c in sorted_compounds:
|
||||
# Word-boundary, case-insensitive. Compound may contain
|
||||
# hyphens or spaces — re.escape handles special chars; word
|
||||
# boundaries on each side prevent partial-word matches
|
||||
# (e.g. "GPT-4" must not match "GPT-40").
|
||||
pattern = r"(?<!\w)" + re.escape(c) + r"(?!\w)"
|
||||
try:
|
||||
compiled.append((c, re.compile(pattern, re.IGNORECASE)))
|
||||
except re.error:
|
||||
continue
|
||||
return tuple(compiled)
|
||||
except (OSError, json.JSONDecodeError, AttributeError, TypeError):
|
||||
return ()
|
||||
|
||||
|
||||
def _apply_known_systems_prepass(text: str) -> tuple[str, dict[str, int]]:
|
||||
"""Scan ``text`` for known-systems compounds, return a working copy
|
||||
with matched spans masked to whitespace plus a dict of detected
|
||||
compound counts.
|
||||
|
||||
Returning the counts (instead of mutating a caller-supplied container)
|
||||
lets the three call sites (``extract_candidates`` at init-time,
|
||||
``palace.build_closet_lines`` at closet construction, and
|
||||
``miner._extract_entities_for_metadata`` at per-drawer tagging) use
|
||||
whichever container shape they already maintain.
|
||||
|
||||
Compounds are matched case-insensitively with word boundaries; the
|
||||
canonical (lexicon) casing is what gets counted, regardless of how
|
||||
the compound appears in the source text. Regexes are pre-compiled
|
||||
once in ``_get_known_systems`` so this function does no compilation.
|
||||
"""
|
||||
compounds = _get_known_systems()
|
||||
if not compounds:
|
||||
return text, {}
|
||||
working = text
|
||||
compound_counts: dict[str, int] = {}
|
||||
for compound, rx in compounds:
|
||||
matches = list(rx.finditer(working))
|
||||
if not matches:
|
||||
continue
|
||||
compound_counts[compound] = compound_counts.get(compound, 0) + len(matches)
|
||||
# Mask matched spans with spaces so the subsequent regex passes
|
||||
# don't re-decompose. Replacing right-to-left keeps earlier
|
||||
# indices stable.
|
||||
for m in reversed(matches):
|
||||
start, end = m.span()
|
||||
working = working[:start] + (" " * (end - start)) + working[end:]
|
||||
return working, compound_counts
|
||||
|
||||
|
||||
# ==================== LANGUAGE-AWARE PATTERN LOADING ====================
|
||||
|
||||
|
||||
@@ -194,13 +286,21 @@ def extract_candidates(text: str, languages=("en",)) -> dict:
|
||||
|
||||
counts: defaultdict = defaultdict(int)
|
||||
|
||||
# Tier 3 — known-systems compound pre-pass. Find compound product names
|
||||
# ("Claude Code", "GitHub Copilot", ...) FIRST and mask them out of the
|
||||
# working text so the subsequent single-word + multi-word loops don't
|
||||
# re-decompose them into their constituent tokens.
|
||||
working_text, compound_counts = _apply_known_systems_prepass(text)
|
||||
for compound, n in compound_counts.items():
|
||||
counts[compound] += n
|
||||
|
||||
# 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):
|
||||
for word in rx.findall(working_text):
|
||||
wl = word.lower()
|
||||
if wl in stopwords:
|
||||
continue
|
||||
@@ -214,13 +314,16 @@ def extract_candidates(text: str, languages=("en",)) -> dict:
|
||||
continue
|
||||
counts[word] += 1
|
||||
|
||||
# Multi-word candidates — one pre-wrapped pattern per language
|
||||
# Multi-word candidates — one pre-wrapped pattern per language.
|
||||
# Runs against the working_text (compounds already masked) so an
|
||||
# unknown two-word phrase like "Jane Smith" still gets caught by
|
||||
# the regex without competing with known compounds.
|
||||
for wrapped_pat in patterns["multi_word_patterns"]:
|
||||
try:
|
||||
rx = re.compile(wrapped_pat)
|
||||
except re.error:
|
||||
continue
|
||||
for phrase in rx.findall(text):
|
||||
for phrase in rx.findall(working_text):
|
||||
if any(w.lower() in stopwords for w in phrase.split()):
|
||||
continue
|
||||
counts[phrase] += 1
|
||||
|
||||
@@ -82,6 +82,8 @@ from .palace import (
|
||||
# mempalace.format_miner.<name>. Lazy imports inside functions would not
|
||||
# expose these as attributes of this module, breaking the test seams.
|
||||
from .config import MempalaceConfig, normalize_wing_name
|
||||
from .collision_scan import assert_no_collisions
|
||||
from .ids import ID_RECIPE, make_drawer_id_from_chunk
|
||||
from .miner import (
|
||||
_compute_topic_tunnels_for_wing,
|
||||
chunk_text,
|
||||
@@ -505,7 +507,7 @@ def scan_formats(directory: Union[Path, str]) -> list[Path]:
|
||||
|
||||
|
||||
def _print_mine_summary(
|
||||
files: list,
|
||||
files_seen: int,
|
||||
files_with_text: int,
|
||||
files_skipped: int,
|
||||
files_errored: int,
|
||||
@@ -521,7 +523,7 @@ def _print_mine_summary(
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" Summary")
|
||||
print(f"{'-' * 55}")
|
||||
print(f" Files seen: {len(files)}")
|
||||
print(f" Files seen: {files_seen}")
|
||||
print(f" Files extracted: {files_with_text}")
|
||||
print(f" Files skipped: {files_skipped}")
|
||||
print(f" Files errored: {files_errored}")
|
||||
@@ -640,8 +642,7 @@ def _file_chunks_locked(
|
||||
batch_ids: list = []
|
||||
batch_metas: list = []
|
||||
for chunk in chunks[batch_start : batch_start + DRAWER_UPSERT_BATCH_SIZE]:
|
||||
key = (source_file + str(chunk["chunk_index"])).encode()
|
||||
drawer_id = f"drawer_{wing}_{room}_{hashlib.sha256(key).hexdigest()[:24]}"
|
||||
drawer_id = make_drawer_id_from_chunk(wing, room, source_file, chunk["chunk_index"])
|
||||
content = chunk["content"]
|
||||
meta: dict = {
|
||||
"wing": wing,
|
||||
@@ -654,6 +655,7 @@ def _file_chunks_locked(
|
||||
"extract_mode": "format",
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"hall": detect_hall(content),
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
if source_mtime is not None:
|
||||
meta["source_mtime"] = source_mtime
|
||||
@@ -674,6 +676,7 @@ def _file_chunks_locked(
|
||||
batch_docs.append(content)
|
||||
batch_ids.append(drawer_id)
|
||||
batch_metas.append(meta)
|
||||
assert_no_collisions(list(zip(batch_ids, batch_metas)), collection)
|
||||
try:
|
||||
collection.upsert(
|
||||
documents=batch_docs,
|
||||
@@ -786,9 +789,11 @@ def mine_formats(
|
||||
files: list = []
|
||||
collection = None
|
||||
total_drawers = 0
|
||||
files_mined = 0
|
||||
files_skipped = 0
|
||||
files_with_text = 0
|
||||
files_errored = 0
|
||||
files_processed = 0
|
||||
status_counts: dict = defaultdict(int)
|
||||
|
||||
try:
|
||||
@@ -796,15 +801,14 @@ def mine_formats(
|
||||
# ``~/docs`` and relative inputs work consistently. Per PR #1555 review
|
||||
# (Copilot #10).
|
||||
files = scan_formats(format_path)
|
||||
if limit > 0:
|
||||
files = files[:limit]
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" MemPalace Mine — Format extraction")
|
||||
print(f"{'=' * 55}")
|
||||
print(f" Wing: {wing}")
|
||||
print(f" Source: {format_path}")
|
||||
print(f" Files: {len(files)}")
|
||||
limit_suffix = f" (limit: {limit} new)" if limit > 0 else ""
|
||||
print(f" Files: {len(files)}{limit_suffix}")
|
||||
print(f" Palace: {palace_path}")
|
||||
if dry_run:
|
||||
print(" DRY RUN — nothing will be filed")
|
||||
@@ -813,6 +817,7 @@ def mine_formats(
|
||||
collection = get_collection(palace_path) if not dry_run else None
|
||||
|
||||
for i, filepath in enumerate(files, 1):
|
||||
files_processed = i
|
||||
source_file = str(filepath)
|
||||
|
||||
# Per-file try/except so one bad file can't crash the whole mine.
|
||||
@@ -873,6 +878,9 @@ def mine_formats(
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] {filepath.name} → {len(chunks)} drawers")
|
||||
total_drawers += len(chunks)
|
||||
files_mined += 1
|
||||
if limit > 0 and files_mined >= limit:
|
||||
break
|
||||
continue
|
||||
|
||||
drawers_added, skipped = _file_chunks_locked(
|
||||
@@ -890,7 +898,10 @@ def mine_formats(
|
||||
continue
|
||||
|
||||
total_drawers += drawers_added
|
||||
files_mined += 1
|
||||
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
|
||||
if limit > 0 and files_mined >= limit:
|
||||
break
|
||||
except Exception as exc:
|
||||
# Log and continue — one malformed file shouldn't kill the
|
||||
# whole mine. Mirrors miner.py's per-file recovery.
|
||||
@@ -970,7 +981,7 @@ def mine_formats(
|
||||
logger.debug("mine_formats: _cleanup_mine_pid_file failed", exc_info=True)
|
||||
|
||||
_print_mine_summary(
|
||||
files=files,
|
||||
files_seen=files_processed,
|
||||
files_with_text=files_with_text,
|
||||
files_skipped=files_skipped,
|
||||
files_errored=files_errored,
|
||||
|
||||
+85
-27
@@ -46,10 +46,11 @@ from .dynamics import initialize_dynamics_fields
|
||||
|
||||
logger = logging.getLogger("mempalace_hallways")
|
||||
|
||||
# Persistence target. Mirrors ``palace_graph._TUNNEL_FILE`` so the storage
|
||||
# pattern is uniform across the two related primitives. Tests override
|
||||
# this via ``monkeypatch.setattr(hallways, "_HALLWAY_FILE", tmp_path/...)``.
|
||||
_HALLWAY_FILE = os.path.join(os.path.expanduser("~"), ".mempalace", "hallways.json")
|
||||
# Persistence target is resolved through ``_get_hallway_file`` below, which
|
||||
# mirrors ``palace_graph._get_tunnel_file`` (the 3.3.6 palace-scoped pattern)
|
||||
# so the storage layout is uniform across the two related primitives. Tests
|
||||
# should monkey-patch ``_get_hallway_file`` and ``_legacy_hallway_file`` rather
|
||||
# than poking a module-level constant.
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
|
||||
@@ -62,36 +63,72 @@ __all__ = [
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Persistence — JSON file at _HALLWAY_FILE, restricted perms (0600) on POSIX
|
||||
# Persistence — JSON file resolved from MempalaceConfig.hallway_file,
|
||||
# restricted perms (0600) on POSIX. Pre-3.3.6 behavior (hardcoded
|
||||
# ~/.mempalace/hallways.json) is kept only as a one-time orphan detection
|
||||
# fallback, matching the palace_graph tunnel-file migration pattern.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _load_hallways() -> list[dict]:
|
||||
"""Read all hallway records. Returns ``[]`` if the file is missing or corrupt."""
|
||||
if not os.path.exists(_HALLWAY_FILE):
|
||||
def _get_hallway_file(config=None) -> str:
|
||||
"""Return the path to the hallways.json file, derived from MempalaceConfig.palace_path."""
|
||||
from .config import MempalaceConfig
|
||||
|
||||
config = config or MempalaceConfig()
|
||||
return config.hallway_file
|
||||
|
||||
|
||||
def _legacy_hallway_file() -> str:
|
||||
"""The pre-palace-scoped hardcoded path. Kept only for one-time orphan detection."""
|
||||
return os.path.join(os.path.expanduser("~"), ".mempalace", "hallways.json")
|
||||
|
||||
|
||||
def _load_hallways(config=None) -> list[dict]:
|
||||
"""Read all hallway records. Returns ``[]`` if the file is missing or corrupt.
|
||||
|
||||
Backwards-compatibility: prior to this migration the hallway file was
|
||||
hardcoded at ``~/.mempalace/hallways.json`` regardless of the configured
|
||||
palace_path. If the configured hallway file is missing but a legacy file
|
||||
exists at a different path, log a one-line warning naming both paths so
|
||||
users can move the file manually. We do NOT auto-migrate — auto-merging
|
||||
hallway state across two locations is too magical for a bugfix and risks
|
||||
clobbering newer data. Same posture as ``palace_graph._load_tunnels``.
|
||||
"""
|
||||
current_hallway_file = _get_hallway_file(config)
|
||||
if os.path.exists(current_hallway_file):
|
||||
try:
|
||||
with open(current_hallway_file, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.debug("hallways: load failed, treating as empty", exc_info=True)
|
||||
return []
|
||||
if isinstance(raw, dict) and "hallways" in raw:
|
||||
return raw.get("hallways") or []
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
return []
|
||||
try:
|
||||
with open(_HALLWAY_FILE, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.debug("hallways: load failed, treating as empty", exc_info=True)
|
||||
return []
|
||||
if isinstance(raw, dict) and "hallways" in raw:
|
||||
return raw.get("hallways") or []
|
||||
if isinstance(raw, list):
|
||||
return raw
|
||||
|
||||
legacy = _legacy_hallway_file()
|
||||
if legacy != current_hallway_file and os.path.exists(legacy):
|
||||
logger.warning(
|
||||
"Legacy hallways file at '%s' is being ignored; configured location is '%s'. "
|
||||
"Move or copy the legacy file to the configured path to recover its hallways.",
|
||||
legacy,
|
||||
current_hallway_file,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _save_hallways(hallways: list[dict]) -> None:
|
||||
"""Atomically persist hallway records to _HALLWAY_FILE.
|
||||
def _save_hallways(hallways: list[dict], config=None) -> None:
|
||||
"""Atomically persist hallway records to the configured hallway file.
|
||||
|
||||
Uses an os.replace temp-file dance so a crash mid-write doesn't
|
||||
corrupt the file. POSIX permission is restricted to 0600 because
|
||||
hallways reveal within-wing entity connections that the user may
|
||||
not want world-readable.
|
||||
"""
|
||||
directory = os.path.dirname(_HALLWAY_FILE)
|
||||
hallway_file = _get_hallway_file(config)
|
||||
directory = os.path.dirname(hallway_file)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
payload = {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
@@ -106,7 +143,7 @@ def _save_hallways(hallways: list[dict]) -> None:
|
||||
except OSError:
|
||||
# Non-POSIX systems may not support chmod; not fatal.
|
||||
pass
|
||||
os.replace(tmp_path, _HALLWAY_FILE)
|
||||
os.replace(tmp_path, hallway_file)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
@@ -179,7 +216,12 @@ def compute_hallways_for_wing(
|
||||
|
||||
Args:
|
||||
wing: wing name to scan.
|
||||
col: ChromaDB collection — must support ``.get(where=..., include=...)``.
|
||||
col: ChromaDB collection — must support ``.count()`` and paginated
|
||||
``.get(limit=..., offset=..., include=...)``. The fetch is filtered
|
||||
to ``wing`` client-side rather than via ``.get(where={"wing": ...})``,
|
||||
which binds one SQL variable per matched id and overflows SQLite's
|
||||
``SQLITE_MAX_VARIABLE_NUMBER`` on large wings (#1619). Fake
|
||||
collections and alternate backends must implement this shape.
|
||||
If ``None``, returns ``[]`` (caller didn't supply a backing
|
||||
store, so nothing to compute against). Tests pass a controlled
|
||||
MagicMock.
|
||||
@@ -198,16 +240,32 @@ def compute_hallways_for_wing(
|
||||
|
||||
min_count = max(1, int(min_count))
|
||||
|
||||
# 1. Query drawers for this wing.
|
||||
# 1. Query drawers for this wing. Paginate the fetch and filter to the
|
||||
# wing client-side: a single get(where={"wing": wing}) binds one SQL
|
||||
# variable per matched id and overflows SQLite's
|
||||
# SQLITE_MAX_VARIABLE_NUMBER (32766) on wings > ~32k drawers (#1619).
|
||||
# Mirrors the established pagination in miner.status / palace /
|
||||
# palace_graph.
|
||||
metadatas: list = []
|
||||
try:
|
||||
results = col.get(where={"wing": wing}, include=["metadatas"])
|
||||
total = col.count()
|
||||
batch_size = 5000
|
||||
offset = 0
|
||||
while offset < total:
|
||||
batch = col.get(limit=batch_size, offset=offset, include=["metadatas"])
|
||||
batch_metas = (batch or {}).get("metadatas") or []
|
||||
if not batch_metas:
|
||||
break
|
||||
metadatas.extend(
|
||||
m for m in batch_metas if isinstance(m, dict) and m.get("wing") == wing
|
||||
)
|
||||
offset += len(batch_metas)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"compute_hallways_for_wing: collection.get failed for %s", wing, exc_info=True
|
||||
"compute_hallways_for_wing: collection fetch failed for %s", wing, exc_info=True
|
||||
)
|
||||
return []
|
||||
|
||||
metadatas = (results or {}).get("metadatas") or []
|
||||
if not metadatas:
|
||||
return []
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Compatibility helpers for legacy shell hooks.
|
||||
|
||||
The shell hooks intentionally stay small and portable, but parsing Claude
|
||||
hook JSON and counting UTF-8 JSONL transcripts is safer in Python than in
|
||||
inline shell snippets. This module centralizes that behavior for both
|
||||
hooks/mempal_save_hook.sh and hooks/mempal_precompact_hook.sh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"[^a-zA-Z0-9_-]")
|
||||
_CONTROL_CHARS_RE = re.compile(r"[\x00\r\n]")
|
||||
|
||||
|
||||
def sanitize_session_id(session_id: object) -> str:
|
||||
"""Keep session ids safe for state-file names."""
|
||||
sanitized = _SESSION_ID_RE.sub("", str(session_id or ""))
|
||||
return sanitized or "unknown"
|
||||
|
||||
|
||||
def normalize_transcript_path(path: object) -> str:
|
||||
r"""Normalize a hook transcript path without destroying Windows paths.
|
||||
|
||||
Claude Code on Windows sends paths like:
|
||||
|
||||
C:\Users\me\.claude\projects\<project>\<session>.jsonl
|
||||
|
||||
The old shell sanitizer removed both the drive-letter colon and
|
||||
backslashes. That turned a valid transcript path into a nonexistent path.
|
||||
For transcript paths, we only remove control characters that would break
|
||||
newline-delimited shell parsing, and normalize backslashes to forward
|
||||
slashes so Git Bash can still address the same Windows file.
|
||||
"""
|
||||
|
||||
normalized = str(path or "").replace("\\", "/")
|
||||
return _CONTROL_CHARS_RE.sub("", normalized)
|
||||
|
||||
|
||||
def _stop_hook_active(value: object) -> str:
|
||||
"""Return the exact boolean string expected by the shell hook."""
|
||||
if value is True:
|
||||
return "True"
|
||||
if str(value).strip().lower() in ("true", "1", "yes"):
|
||||
return "True"
|
||||
return "False"
|
||||
|
||||
|
||||
def parse_stop_payload(payload: dict) -> tuple[str, str, str]:
|
||||
return (
|
||||
sanitize_session_id(payload.get("session_id", "")),
|
||||
_stop_hook_active(payload.get("stop_hook_active", False)),
|
||||
normalize_transcript_path(payload.get("transcript_path", "")),
|
||||
)
|
||||
|
||||
|
||||
def parse_precompact_payload(payload: dict) -> tuple[str, str]:
|
||||
return (
|
||||
sanitize_session_id(payload.get("session_id", "")),
|
||||
normalize_transcript_path(payload.get("transcript_path", "")),
|
||||
)
|
||||
|
||||
|
||||
def count_human_messages(path: str) -> int:
|
||||
"""Count user messages in a Claude transcript JSONL file.
|
||||
|
||||
Claude transcripts are UTF-8. Windows Python defaults to cp1252 in many
|
||||
environments, so the encoding must be explicit. Invalid bytes are ignored
|
||||
to match the hooks' fail-soft behavior.
|
||||
"""
|
||||
|
||||
count = 0
|
||||
with open(path, encoding="utf-8", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
msg = entry.get("message", {})
|
||||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||||
continue
|
||||
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and "<command-message>" in content:
|
||||
continue
|
||||
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def _load_stdin_json() -> dict:
|
||||
raw = sys.stdin.read()
|
||||
|
||||
# Empty stdin is a legitimate hook state. Treat it as an empty payload so
|
||||
# the sentinel is printed and the shell fail-loud guard does not spam disk.
|
||||
if raw == "":
|
||||
return {}
|
||||
|
||||
# For non-empty malformed input, intentionally let json.loads raise.
|
||||
# The shell hooks capture this stderr in last_python_err.log and, because
|
||||
# no sentinel is printed, write a bounded copy of the raw payload to
|
||||
# last_input.log. That fail-loud contract is pinned by
|
||||
# tests/test_hooks_bash_compat.py.
|
||||
data = json.loads(raw)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError(f"hook input must be a JSON object, got {type(data).__name__}")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv:
|
||||
print(
|
||||
"usage: python -m mempalace.hook_shell <parse-stop|parse-precompact|count-human-messages>",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
command = argv[0]
|
||||
|
||||
if command == "parse-stop":
|
||||
session_id, stop_hook_active, transcript_path = parse_stop_payload(_load_stdin_json())
|
||||
print("__MEMPAL_PARSE_OK__")
|
||||
print(session_id)
|
||||
print(stop_hook_active)
|
||||
print(transcript_path)
|
||||
return 0
|
||||
|
||||
if command == "parse-precompact":
|
||||
session_id, transcript_path = parse_precompact_payload(_load_stdin_json())
|
||||
print("__MEMPAL_PARSE_OK__")
|
||||
print(session_id)
|
||||
print(transcript_path)
|
||||
return 0
|
||||
|
||||
if command == "count-human-messages":
|
||||
if len(argv) != 2:
|
||||
print("count-human-messages requires a transcript path", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
print(count_human_messages(argv[1]))
|
||||
except Exception:
|
||||
print(0)
|
||||
return 0
|
||||
|
||||
print(f"unknown hook_shell command: {command}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+27
-5
@@ -651,12 +651,16 @@ def _save_diary_direct(
|
||||
session_id: str,
|
||||
wing: str = "",
|
||||
toast: bool = False,
|
||||
*,
|
||||
agent_name: str,
|
||||
) -> dict:
|
||||
"""Write a diary checkpoint by calling the tool function directly (no MCP roundtrip).
|
||||
|
||||
If `wing` is set, the entry lands in that wing (typically the project wing
|
||||
derived from the transcript path). Otherwise falls back to `tool_diary_write`'s
|
||||
default of `wing_session-hook`.
|
||||
The entry is filed under `agent_name` so the agent that later calls
|
||||
`mempalace_diary_read(agent_name=...)` discovers it (#1693). If `wing` is
|
||||
set, the entry lands in that wing (typically the project wing derived from
|
||||
the transcript path); a `diary_read` with an empty wing spans every wing
|
||||
the agent wrote to, so project-derived wings stay discoverable.
|
||||
|
||||
Returns {"count": N, "themes": [...]} on success, {"count": 0} on failure.
|
||||
"""
|
||||
@@ -679,7 +683,7 @@ def _save_diary_direct(
|
||||
from .mcp_server import tool_diary_write
|
||||
|
||||
result = tool_diary_write(
|
||||
agent_name="session-hook",
|
||||
agent_name=agent_name,
|
||||
entry=entry,
|
||||
topic="checkpoint",
|
||||
wing=wing,
|
||||
@@ -741,6 +745,20 @@ def _ingest_transcript(transcript_path: str):
|
||||
SUPPORTED_HARNESSES = {"claude-code", "codex"}
|
||||
|
||||
|
||||
def _diary_agent_for_harness(harness: str) -> str:
|
||||
"""Return the diary ``agent_name`` a session in ``harness`` reads under.
|
||||
|
||||
Stop-hook checkpoints must be filed beside the agent's own entries so
|
||||
``mempalace_diary_read(agent_name=...)`` surfaces them. The old code filed
|
||||
them under a fixed ``"session-hook"`` identity that no reader ever queried,
|
||||
hiding every checkpoint (#1693). A ``claude-code`` session reads its diary
|
||||
as ``"claude"``; every other harness already reads under its own name, so
|
||||
returning the harness name keeps a newly supported harness discoverable
|
||||
instead of silently invisible again.
|
||||
"""
|
||||
return "claude" if harness == "claude-code" else harness
|
||||
|
||||
|
||||
def _parse_harness_input(data: dict, harness: str) -> dict:
|
||||
"""Parse stdin JSON according to the harness type."""
|
||||
if harness not in SUPPORTED_HARNESSES:
|
||||
@@ -942,7 +960,11 @@ def hook_stop(data: dict, harness: str):
|
||||
result = {"count": 0}
|
||||
if transcript_path:
|
||||
result = _save_diary_direct(
|
||||
transcript_path, session_id, wing=project_wing, toast=toast
|
||||
transcript_path,
|
||||
session_id,
|
||||
wing=project_wing,
|
||||
toast=toast,
|
||||
agent_name=_diary_agent_for_harness(harness),
|
||||
)
|
||||
_ingest_transcript(transcript_path)
|
||||
_maybe_auto_ingest()
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Centralized drawer/triple ID construction with collision-safe delimiter.
|
||||
|
||||
Drawer IDs and content-addressed identifiers built by concatenating strings
|
||||
without a delimiter before hashing form a defect class that allows
|
||||
``hash(s1 + str(i1)) == hash(s2 + str(i2))`` whenever
|
||||
``s1 + str(i1) == s2 + str(i2)``. Under ChromaDB's primary-key constraint
|
||||
the second upsert silently overwrites the first, losing content with no
|
||||
error raised. The styleguide's partial-scope-key-migration rule names this
|
||||
shape — every concat-into-hash site is a candidate that must be triaged.
|
||||
|
||||
This module is the single source of truth for ID construction in mempalace.
|
||||
All call sites use the named helpers below; no module should inline
|
||||
``hashlib.sha256(a + b)`` patterns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
# Recipe tag written to every drawer's metadata under this module's helpers.
|
||||
# Audits compare like-for-like: drawers without ``id_recipe`` are treated
|
||||
# as legacy ``v1`` (pre-delimiter recipe), drawers with ``id_recipe="v2"``
|
||||
# are guaranteed collision-safe within the v2 generation. The constant is
|
||||
# exported so call sites use ``ids.ID_RECIPE`` rather than a magic string.
|
||||
ID_RECIPE: str = "v3"
|
||||
|
||||
# '|' is reserved in Windows filenames and cannot appear in source paths
|
||||
# on any supported platform, making it strictly safer than ':' (which
|
||||
# appears in Windows drive letters and URL ports). Matches the existing
|
||||
# diary_ingest precedent at diary_ingest.py:52,76,91,98.
|
||||
_DELIM: str = "|"
|
||||
|
||||
# SHA-256 hex truncation lengths. Drawer IDs historically truncate at 24
|
||||
# chars; knowledge-graph triple IDs at 12. Preserved per-recipe so existing
|
||||
# fixture comparisons that hard-code truncation length still parse.
|
||||
_HASH_TRUNC_DRAWER: int = 24
|
||||
_HASH_TRUNC_TRIPLE: int = 12
|
||||
|
||||
|
||||
def _delimited_sha256(parts: tuple[object, ...], truncate: int) -> str:
|
||||
"""Hash parts joined by the unambiguous delimiter, truncate to N hex chars.
|
||||
|
||||
Internal helper. Call sites should use the named ``make_*`` wrappers
|
||||
below so the per-site contract is documented in code, not derived
|
||||
from caller arguments.
|
||||
|
||||
Each part is coerced to ``str`` before joining so the helper mirrors
|
||||
the pre-v2 behavior of ``f"{a}{b}"`` for ``None`` and numeric inputs —
|
||||
e.g. ``valid_from=None`` joins as the literal string ``"None"`` rather
|
||||
than crashing.
|
||||
"""
|
||||
key = "".join(f"{len(part)}:{part}" for part in map(str, parts)).encode()
|
||||
return hashlib.sha256(key).hexdigest()[:truncate]
|
||||
|
||||
|
||||
def make_drawer_id_from_chunk(wing: str, room: str, source_file: str, chunk_index: int) -> str:
|
||||
"""Drawer ID for the project / format miner paths.
|
||||
|
||||
Hash input is ``f"{source_file}|{chunk_index}"`` — the '|' separator
|
||||
prevents the classic ``"/a1" + "23" == "/a" + "123"`` collision.
|
||||
|
||||
Returns ``drawer_{wing}_{room}_{hash24}`` where hash24 is the first
|
||||
24 hex chars of SHA-256 over the delimited input.
|
||||
"""
|
||||
return (
|
||||
f"drawer_{wing}_{room}_"
|
||||
f"{_delimited_sha256((source_file, str(chunk_index)), _HASH_TRUNC_DRAWER)}"
|
||||
)
|
||||
|
||||
|
||||
def make_drawer_id_from_content(wing: str, room: str, content: str) -> str:
|
||||
"""Drawer ID for the MCP ``add_drawer`` tool path.
|
||||
|
||||
Hash input is ``f"{wing}|{room}|{content}"`` — the delimiters prevent
|
||||
``wing="foo" + room="bar"`` colliding with ``wing="fooba" + room="r"``
|
||||
(architecturally identical defect class to the chunk-index sites,
|
||||
even though astronomically rare in practice since content is large
|
||||
freeform text).
|
||||
"""
|
||||
return f"drawer_{wing}_{room}_{_delimited_sha256((wing, room, content), _HASH_TRUNC_DRAWER)}"
|
||||
|
||||
|
||||
def make_convo_drawer_id(
|
||||
wing: str, room: str, source_file: str, extract_mode: str, chunk_index: int
|
||||
) -> str:
|
||||
"""Drawer ID for the conversation miner path.
|
||||
|
||||
Pre-v2 the convo miner used ':' as delimiter; this helper migrates
|
||||
to '|' for codebase-wide consistency and to remove the Windows-path
|
||||
/ URL-source edge case that ':' carried.
|
||||
|
||||
Hash input is ``f"{source_file}|{extract_mode}|{chunk_index}"``.
|
||||
"""
|
||||
return (
|
||||
f"drawer_{wing}_{room}_"
|
||||
f"{_delimited_sha256((source_file, extract_mode, str(chunk_index)), _HASH_TRUNC_DRAWER)}"
|
||||
)
|
||||
|
||||
|
||||
def make_convo_sentinel_id(source_file: str, extract_mode: str) -> str:
|
||||
"""Sentinel registry ID for the conversation miner zero-chunk-file path.
|
||||
|
||||
Pre-v2 the sentinel used ':' as delimiter; this helper migrates to
|
||||
'|' for the same reasons as ``make_convo_drawer_id``.
|
||||
|
||||
Hash input is ``f"{source_file}|{extract_mode}"``.
|
||||
"""
|
||||
return f"_reg_{_delimited_sha256((source_file, extract_mode), _HASH_TRUNC_DRAWER)}"
|
||||
|
||||
|
||||
def make_triple_id(
|
||||
sub_id: str, predicate: str, obj_id: str, valid_from: str, recorded_at: str
|
||||
) -> str:
|
||||
"""Triple ID for knowledge-graph insertion.
|
||||
|
||||
Pre-v2 the recorded_at hash input was
|
||||
``f"{valid_from}{datetime.now().isoformat()}"`` with no delimiter —
|
||||
two ISO datetimes concatenated could collide in principle (e.g.
|
||||
``valid_from="2026-01-01" + isoformat "T12:..."`` vs
|
||||
``valid_from="2026-01-01T12" + isoformat ":..."``).
|
||||
|
||||
Returns ``t_{sub_id}_{predicate}_{obj_id}_{hash12}`` where hash12 is
|
||||
the first 12 hex chars of SHA-256 over ``f"{valid_from}|{recorded_at}"``.
|
||||
"""
|
||||
return (
|
||||
f"t_{sub_id}_{predicate}_{obj_id}_"
|
||||
f"{_delimited_sha256((valid_from, recorded_at), _HASH_TRUNC_TRIPLE)}"
|
||||
)
|
||||
@@ -35,7 +35,6 @@ Usage:
|
||||
kg.invalidate("Max", "has_issue", "sports_injury", ended="2026-02-15")
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
@@ -44,6 +43,7 @@ from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .config import sanitize_iso_temporal
|
||||
from .ids import make_triple_id
|
||||
|
||||
|
||||
DEFAULT_KG_PATH = os.path.expanduser("~/.mempalace/knowledge_graph.sqlite3")
|
||||
@@ -302,7 +302,9 @@ class KnowledgeGraph:
|
||||
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 = make_triple_id(
|
||||
sub_id, pred, obj_id, valid_from, datetime.now().isoformat()
|
||||
)
|
||||
conn.execute(
|
||||
"""INSERT INTO triples (
|
||||
id, subject, predicate, object, valid_from, valid_to,
|
||||
|
||||
+10
-3
@@ -23,7 +23,12 @@ from collections import defaultdict
|
||||
|
||||
from .config import MempalaceConfig
|
||||
from .palace import get_collection as _get_collection
|
||||
from .searcher import _first_or_empty, build_where_filter
|
||||
from .searcher import (
|
||||
_distance_to_similarity,
|
||||
_first_or_empty,
|
||||
_metric_for_collection,
|
||||
build_where_filter,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -283,11 +288,12 @@ class Layer3:
|
||||
if not docs:
|
||||
return "No results found."
|
||||
|
||||
metric = _metric_for_collection(col)
|
||||
lines = [f'## L3 — SEARCH RESULTS for "{query}"']
|
||||
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
|
||||
meta = meta or {}
|
||||
doc = doc or ""
|
||||
similarity = round(max(0.0, 1 - dist), 3)
|
||||
similarity = round(_distance_to_similarity(dist, metric), 3)
|
||||
wing_name = meta.get("wing", "?")
|
||||
room_name = meta.get("room", "?")
|
||||
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
|
||||
@@ -327,6 +333,7 @@ class Layer3:
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
metric = _metric_for_collection(col)
|
||||
hits = []
|
||||
for doc, meta, dist in zip(
|
||||
_first_or_empty(results, "documents"),
|
||||
@@ -346,7 +353,7 @@ class Layer3:
|
||||
"wing": meta.get("wing", "unknown"),
|
||||
"room": meta.get("room", "unknown"),
|
||||
"source_file": Path(meta.get("source_file", "?")).name,
|
||||
"similarity": round(1 - dist, 3),
|
||||
"similarity": round(_distance_to_similarity(dist, metric), 3),
|
||||
"metadata": meta,
|
||||
}
|
||||
)
|
||||
|
||||
+1097
-154
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import errno
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
@@ -28,6 +29,9 @@ from collections import defaultdict
|
||||
from contextlib import closing
|
||||
from datetime import datetime
|
||||
|
||||
from .backups import prune_backups
|
||||
from .config import MempalaceConfig
|
||||
|
||||
|
||||
def _restore_stale_palace(palace_path: str, stale_path: str) -> None:
|
||||
"""Roll back a failed swap.
|
||||
@@ -293,6 +297,16 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
||||
print(f"\n Backing up to {backup_path}...")
|
||||
shutil.copytree(palace_path, backup_path)
|
||||
|
||||
# Enforce backup retention so repeated migrations cannot fill the disk
|
||||
# with full-palace copies. The backup we just created is the newest, so
|
||||
# it survives; only older ``.pre-migrate.*`` siblings beyond the limit
|
||||
# are removed. Best-effort — never let cleanup fail the migration.
|
||||
prune_backups(
|
||||
glob.escape(palace_path) + ".pre-migrate.*",
|
||||
MempalaceConfig().max_backups,
|
||||
log=print,
|
||||
)
|
||||
|
||||
# Build fresh palace in a temp directory (avoids chromadb reading old state).
|
||||
# Wrap the whole import-and-swap dance in try/finally so the temp dir is
|
||||
# cleaned up if any of the chromadb writes, the verify count, or the
|
||||
@@ -360,3 +374,218 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
|
||||
|
||||
print(f"\n{'=' * 60}\n")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wing-name normalization migration (#1675 follow-up)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# normalize_wing_name now strips leading/trailing separators, so a path-encoded
|
||||
# dirname like ``-home-user-proj`` derives ``home_user_proj`` instead of
|
||||
# ``_home_user_proj``. Palaces built before that rule filed drawers under the
|
||||
# old, leading-underscore wing, which the new derivation no longer matches —
|
||||
# searches and diary reads under the new name miss the old memories.
|
||||
#
|
||||
# This migration re-keys the ``wing`` metadata field on drawers and closets to
|
||||
# the normalized form, merging collisions. Drawer/closet IDs embed the wing as
|
||||
# an opaque prefix that is never decoded back into a wing (verified: nothing
|
||||
# splits a wing out of an ID; mining idempotency keys on ``source_file``), so
|
||||
# the IDs are left untouched — closet ``→drawer_id`` pointers stay valid and
|
||||
# future mining still skips already-mined files. Tunnels resolve via existing
|
||||
# read-time normalization and need no rewrite. The pass is idempotent.
|
||||
|
||||
|
||||
def _normalized_wing_target(wing):
|
||||
"""Return the normalized wing if it differs from ``wing``, else ``None``.
|
||||
|
||||
``None`` means "no migration needed" — either the value is not a non-empty
|
||||
string, normalization is a no-op, or it would normalize to empty.
|
||||
"""
|
||||
from .config import normalize_wing_name
|
||||
|
||||
if not isinstance(wing, str) or not wing:
|
||||
return None
|
||||
# Apply the full normalization and explicitly strip leading/trailing
|
||||
# separators. The strip is this migration's whole purpose (#1675); doing it
|
||||
# here rather than relying on normalize_wing_name keeps the migration correct
|
||||
# even when run against a build whose normalize_wing_name predates #1675, and
|
||||
# matches the post-#1675 derivation exactly.
|
||||
target = normalize_wing_name(wing).strip("_")
|
||||
if not target or target == wing:
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def plan_wing_renames(items):
|
||||
"""Pure planner over ``(id, metadata)`` pairs.
|
||||
|
||||
Returns ``(summary, updates)`` where ``summary`` is ``{(old, new): count}``
|
||||
and ``updates`` is ``[(id, new_metadata), ...]`` for only the records whose
|
||||
wing changes. Metadata is copied; only the ``wing`` key is rewritten.
|
||||
"""
|
||||
summary = defaultdict(int)
|
||||
updates = []
|
||||
for rec_id, meta in items:
|
||||
meta = dict(meta or {})
|
||||
target = _normalized_wing_target(meta.get("wing"))
|
||||
if target is None:
|
||||
continue
|
||||
summary[(meta["wing"], target)] += 1
|
||||
meta["wing"] = target
|
||||
updates.append((rec_id, meta))
|
||||
return summary, updates
|
||||
|
||||
|
||||
def _iter_collection_items(col, batch_size=1000):
|
||||
"""Yield ``(id, metadata)`` for every record in a backend collection."""
|
||||
total = col.count()
|
||||
offset = 0
|
||||
while offset < total:
|
||||
batch = col.get(limit=batch_size, offset=offset, include=["metadatas"])
|
||||
ids = batch.ids if hasattr(batch, "ids") else batch["ids"]
|
||||
metas = batch.metadatas if hasattr(batch, "metadatas") else batch["metadatas"]
|
||||
if not ids:
|
||||
break
|
||||
for rec_id, meta in zip(ids, metas):
|
||||
yield rec_id, meta
|
||||
offset += len(ids)
|
||||
|
||||
|
||||
def _apply_wing_updates(col, updates, batch_size=500):
|
||||
"""Re-label the ``wing`` metadata field in place for the planned updates."""
|
||||
for i in range(0, len(updates), batch_size):
|
||||
chunk = updates[i : i + batch_size]
|
||||
col.update(ids=[u[0] for u in chunk], metadatas=[u[1] for u in chunk])
|
||||
|
||||
|
||||
def _plan_topics_by_wing_renames():
|
||||
"""Return ``{old_wing: new_wing}`` for ``topics_by_wing`` keys to normalize."""
|
||||
try:
|
||||
from .miner import _load_known_entities_raw
|
||||
|
||||
reg = _load_known_entities_raw()
|
||||
except Exception:
|
||||
return {}
|
||||
tbw = reg.get("topics_by_wing")
|
||||
if not isinstance(tbw, dict):
|
||||
return {}
|
||||
renames = {}
|
||||
for wing in list(tbw.keys()):
|
||||
target = _normalized_wing_target(wing)
|
||||
if target is not None:
|
||||
renames[wing] = target
|
||||
return renames
|
||||
|
||||
|
||||
def _apply_topics_by_wing_renames(renames):
|
||||
"""Re-key ``topics_by_wing`` in known_entities.json, merging on collision."""
|
||||
if not renames:
|
||||
return
|
||||
import json
|
||||
|
||||
from .miner import _ENTITY_REGISTRY_PATH, _load_known_entities_raw
|
||||
|
||||
try:
|
||||
reg = _load_known_entities_raw()
|
||||
except Exception:
|
||||
return
|
||||
tbw = reg.get("topics_by_wing")
|
||||
if not isinstance(tbw, dict):
|
||||
return
|
||||
for old, new in renames.items():
|
||||
if old not in tbw:
|
||||
continue
|
||||
old_topics = tbw.pop(old) or []
|
||||
if new in tbw:
|
||||
merged = list(tbw[new])
|
||||
for topic in old_topics:
|
||||
if topic not in merged:
|
||||
merged.append(topic)
|
||||
tbw[new] = merged
|
||||
else:
|
||||
tbw[new] = old_topics
|
||||
reg["topics_by_wing"] = tbw
|
||||
os.makedirs(os.path.dirname(_ENTITY_REGISTRY_PATH), exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(_ENTITY_REGISTRY_PATH), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(reg, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, _ENTITY_REGISTRY_PATH)
|
||||
except Exception:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def migrate_wing_names(palace_path: str, dry_run: bool = False, confirm: bool = False) -> bool:
|
||||
"""Normalize legacy wing names in ``palace_path`` (strip leading/trailing
|
||||
separators), so palaces built before #1675 keep their memories discoverable.
|
||||
|
||||
Re-keys the ``wing`` metadata on drawers and closets in place (IDs untouched)
|
||||
and the ``topics_by_wing`` registry, merging collisions. Idempotent.
|
||||
|
||||
Returns True if anything was (or, in dry-run, would be) migrated.
|
||||
"""
|
||||
from .palace import get_closets_collection, get_collection
|
||||
|
||||
try:
|
||||
drawers = get_collection(palace_path, create=False)
|
||||
except Exception as exc:
|
||||
print(f" No drawer collection found at {palace_path} ({exc}).")
|
||||
return False
|
||||
|
||||
d_items = list(_iter_collection_items(drawers))
|
||||
all_wings = {(m or {}).get("wing") for _, m in d_items if (m or {}).get("wing")}
|
||||
d_summary, d_updates = plan_wing_renames(d_items)
|
||||
|
||||
closets = None
|
||||
c_summary, c_updates = defaultdict(int), []
|
||||
try:
|
||||
closets = get_closets_collection(palace_path, create=False)
|
||||
c_summary, c_updates = plan_wing_renames(_iter_collection_items(closets))
|
||||
except Exception:
|
||||
closets = None
|
||||
|
||||
topic_renames = _plan_topics_by_wing_renames()
|
||||
|
||||
if not d_updates and not c_updates and not topic_renames:
|
||||
print(" All wing names are already normalized — nothing to migrate.")
|
||||
return False
|
||||
|
||||
print("\n Wing-name migration plan:")
|
||||
merged = defaultdict(lambda: [0, 0])
|
||||
for key, count in d_summary.items():
|
||||
merged[key][0] = count
|
||||
for key, count in c_summary.items():
|
||||
merged[key][1] = count
|
||||
for (old, new), (d_count, c_count) in sorted(merged.items()):
|
||||
note = " (MERGE into existing wing)" if new in all_wings else ""
|
||||
print(f" {old!r} -> {new!r}: {d_count} drawer(s), {c_count} closet(s){note}")
|
||||
if topic_renames:
|
||||
print(f" topics_by_wing: {len(topic_renames)} key(s) re-keyed")
|
||||
|
||||
if dry_run:
|
||||
print("\n DRY RUN — no changes made.\n")
|
||||
return True
|
||||
|
||||
if not confirm:
|
||||
try:
|
||||
resp = input(" Apply this wing-name migration? [y/N] ").strip().lower()
|
||||
except EOFError:
|
||||
resp = ""
|
||||
if resp not in ("y", "yes"):
|
||||
print(" Aborted.")
|
||||
return False
|
||||
|
||||
_apply_wing_updates(drawers, d_updates)
|
||||
if closets is not None and c_updates:
|
||||
_apply_wing_updates(closets, c_updates)
|
||||
_apply_topics_by_wing_renames(topic_renames)
|
||||
|
||||
parts = [f"{len(d_updates)} drawer(s)"]
|
||||
if c_updates:
|
||||
parts.append(f"{len(c_updates)} closet(s)")
|
||||
if topic_renames:
|
||||
parts.append(f"{len(topic_renames)} topic key(s)")
|
||||
print(f"\n Migrated {', '.join(parts)}.\n")
|
||||
return True
|
||||
|
||||
+45
-13
@@ -19,7 +19,7 @@ from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from typing import Optional
|
||||
|
||||
from .entity_detector import _get_coca_filter
|
||||
from .entity_detector import _apply_known_systems_prepass, _get_coca_filter
|
||||
from .palace import (
|
||||
NORMALIZE_VERSION,
|
||||
SKIP_DIRS,
|
||||
@@ -41,7 +41,9 @@ from .palace import (
|
||||
# ``mempalace.miner.compute_hallways_for_wing``. The integration call
|
||||
# lives at the end of _mine_impl, alongside the existing
|
||||
# ``_compute_topic_tunnels_for_wing`` post-mine block.
|
||||
from .collision_scan import assert_no_collisions
|
||||
from .hallways import compute_hallways_for_wing
|
||||
from .ids import ID_RECIPE, make_drawer_id_from_chunk
|
||||
|
||||
logger = logging.getLogger("mempalace_mcp")
|
||||
|
||||
@@ -871,8 +873,14 @@ def _extract_entities_for_metadata(content: str) -> str:
|
||||
|
||||
coca_filter = _get_coca_filter()
|
||||
window = content[:_ENTITY_EXTRACT_WINDOW]
|
||||
words = _candidate_entity_words(window)
|
||||
freq: dict = {}
|
||||
# Tier 3 linguistics cleanup — known-systems compound pre-pass. Detects
|
||||
# multi-word product names atomically and masks them from the window so
|
||||
# the single-word extraction below doesn't decompose them into their
|
||||
# constituent tokens (which would then either get COCA-filtered or
|
||||
# appear as wrongly-attributed standalone entities).
|
||||
working_window, compound_counts = _apply_known_systems_prepass(window)
|
||||
words = _candidate_entity_words(working_window)
|
||||
freq: dict = dict(compound_counts)
|
||||
for w in words:
|
||||
if w in _ENTITY_STOPLIST:
|
||||
continue
|
||||
@@ -1219,6 +1227,7 @@ def _build_drawer_metadata(
|
||||
"added_by": agent,
|
||||
"filed_at": datetime.now().isoformat(),
|
||||
"normalize_version": NORMALIZE_VERSION,
|
||||
"id_recipe": ID_RECIPE,
|
||||
}
|
||||
if source_mtime is not None:
|
||||
metadata["source_mtime"] = source_mtime
|
||||
@@ -1244,7 +1253,7 @@ def add_drawer(
|
||||
miner uses ``_build_drawer_metadata`` + a batched ``collection.upsert``
|
||||
to amortize the embedding model's forward-pass cost across chunks.
|
||||
"""
|
||||
drawer_id = f"drawer_{wing}_{room}_{hashlib.sha256((source_file + str(chunk_index)).encode()).hexdigest()[:24]}"
|
||||
drawer_id = make_drawer_id_from_chunk(wing, room, source_file, chunk_index)
|
||||
try:
|
||||
source_mtime = os.path.getmtime(source_file)
|
||||
except OSError:
|
||||
@@ -1377,7 +1386,7 @@ def process_file(
|
||||
batch_ids: list = []
|
||||
batch_metas: list = []
|
||||
for chunk in chunks[batch_start : batch_start + DRAWER_UPSERT_BATCH_SIZE]:
|
||||
drawer_id = f"drawer_{wing}_{room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
|
||||
drawer_id = make_drawer_id_from_chunk(wing, room, source_file, chunk["chunk_index"])
|
||||
batch_docs.append(chunk["content"])
|
||||
batch_ids.append(drawer_id)
|
||||
batch_metas.append(
|
||||
@@ -1394,6 +1403,7 @@ def process_file(
|
||||
content_date=file_content_date,
|
||||
)
|
||||
)
|
||||
assert_no_collisions(list(zip(batch_ids, batch_metas)), collection)
|
||||
collection.upsert(
|
||||
documents=batch_docs,
|
||||
ids=batch_ids,
|
||||
@@ -1407,8 +1417,7 @@ def process_file(
|
||||
# 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
|
||||
make_drawer_id_from_chunk(wing, room, source_file, c["chunk_index"]) for c in chunks
|
||||
]
|
||||
# Pass drawer_metas so build_closet_lines can emit the Tier 6a
|
||||
# 4-segment pointer (``topic|entities|YYYY-MM-DD:Lstart-Lend|→ids``)
|
||||
@@ -1614,9 +1623,6 @@ def _mine_impl(
|
||||
respect_gitignore=respect_gitignore,
|
||||
include_ignored=include_ignored,
|
||||
)
|
||||
if limit > 0:
|
||||
files = files[:limit]
|
||||
|
||||
from .embedding import describe_device
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
@@ -1624,7 +1630,8 @@ def _mine_impl(
|
||||
print(f"{'=' * 55}")
|
||||
print(f" Wing: {wing}")
|
||||
print(f" Rooms: {', '.join(r['name'] for r in rooms)}")
|
||||
print(f" Files: {len(files)}")
|
||||
limit_suffix = f" (limit: {limit} new)" if limit > 0 else ""
|
||||
print(f" Files: {len(files)}{limit_suffix}")
|
||||
print(f" Palace: {palace_path}")
|
||||
print(f" Device: {describe_device()}")
|
||||
if dry_run:
|
||||
@@ -1643,6 +1650,7 @@ def _mine_impl(
|
||||
closets_col = None
|
||||
|
||||
total_drawers = 0
|
||||
files_mined = 0
|
||||
files_skipped = 0
|
||||
files_skipped_chunk_cap = 0
|
||||
files_processed = 0
|
||||
@@ -1690,8 +1698,11 @@ def _mine_impl(
|
||||
else:
|
||||
total_drawers += drawers
|
||||
room_counts[room] += 1
|
||||
files_mined += 1
|
||||
if not dry_run:
|
||||
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers}")
|
||||
if limit > 0 and files_mined >= limit:
|
||||
break
|
||||
|
||||
if not dry_run:
|
||||
# Cross-wing topic tunnels: after every file in this wing has been
|
||||
@@ -1744,7 +1755,7 @@ def _mine_impl(
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
print(" Done.")
|
||||
print(f" Files processed: {len(files) - files_skipped}")
|
||||
print(f" Files processed: {files_processed - files_skipped}")
|
||||
# The residual skip bucket label depends on mode: dry-run bypasses
|
||||
# the already-mined check, so the only paths producing (0, room,
|
||||
# None) under dry_run are OSError / too-short / post-lock re-check
|
||||
@@ -1905,7 +1916,23 @@ def _compute_entity_tunnels_for_wing(wing: str) -> int:
|
||||
|
||||
|
||||
def status(palace_path: str):
|
||||
"""Show what's been filed in the palace."""
|
||||
"""Show what's been filed in the palace.
|
||||
|
||||
Tallies drawers by wing/room directly from ``chroma.sqlite3`` so a routine
|
||||
status check never cold-loads the HNSW vector index — a load that costs
|
||||
tens of seconds of CPU per call on large palaces (#1681). Falls back to the
|
||||
ChromaDB client path when the sqlite read is unavailable (missing DB,
|
||||
un-bootstrapped collection, or an unexpected schema); the fallback also
|
||||
emits the state-specific guidance for absent/empty palaces.
|
||||
"""
|
||||
from .backends.chroma import _sqlite_wing_room_counts
|
||||
|
||||
counts = _sqlite_wing_room_counts(palace_path, "mempalace_drawers")
|
||||
if counts is not None:
|
||||
total, wing_rooms = counts
|
||||
_print_status(total, wing_rooms)
|
||||
return
|
||||
|
||||
col = _open_collection_or_explain(palace_path)
|
||||
if col is None:
|
||||
return
|
||||
@@ -1926,6 +1953,11 @@ def status(palace_path: str):
|
||||
wing_rooms[m.get("wing", "?")][m.get("room", "?")] += 1
|
||||
offset += len(batch)
|
||||
|
||||
_print_status(total, wing_rooms)
|
||||
|
||||
|
||||
def _print_status(total: int, wing_rooms: dict[str, dict[str, int]]) -> None:
|
||||
"""Render the wing/room histogram shared by both status code paths."""
|
||||
print(f"\n{'=' * 55}")
|
||||
print(f" MemPalace Status — {total} drawers")
|
||||
print(f"{'=' * 55}\n")
|
||||
|
||||
@@ -122,7 +122,7 @@ def normalize(filepath: str) -> str:
|
||||
if file_size > 500 * 1024 * 1024: # 500 MB safety limit
|
||||
raise IOError(f"File too large ({file_size // (1024 * 1024)} MB): {filepath}")
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||
with open(filepath, "r", encoding="utf-8-sig", errors="replace") as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
raise IOError(f"Could not read {filepath}: {e}") from e
|
||||
@@ -523,6 +523,8 @@ 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 isinstance(inp, list):
|
||||
inp = {}
|
||||
|
||||
if name == "Bash":
|
||||
cmd = inp.get("command", "")
|
||||
|
||||
+541
-83
@@ -13,9 +13,20 @@ import sys
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from .backends import BackendClosedError, CollectionNotInitializedError, PalaceNotFoundError
|
||||
from .backends.chroma import ChromaBackend
|
||||
from .entity_detector import _get_coca_filter
|
||||
from .backends import (
|
||||
BackendClosedError,
|
||||
BackendMismatchError,
|
||||
CollectionNotInitializedError,
|
||||
PalaceNotFoundError,
|
||||
PalaceRef,
|
||||
detect_backend_for_path,
|
||||
detect_backends_for_path,
|
||||
get_backend,
|
||||
get_backend_class,
|
||||
resolve_backend_for_palace,
|
||||
)
|
||||
from .backends.embedding_wrapper import EmbeddingCollection
|
||||
from .entity_detector import _apply_known_systems_prepass, _get_coca_filter
|
||||
|
||||
logger = logging.getLogger("mempalace_mcp")
|
||||
|
||||
@@ -45,7 +56,8 @@ SKIP_DIRS = {
|
||||
"target",
|
||||
}
|
||||
|
||||
_DEFAULT_BACKEND = ChromaBackend()
|
||||
_DEFAULT_BACKEND = get_backend("chroma")
|
||||
_EXPLICIT_BACKEND_ENV = "MEMPALACE_BACKEND_EXPLICIT"
|
||||
|
||||
# Schema version for drawer normalization. Bump when the normalization
|
||||
# pipeline changes in a way that existing drawers should be rebuilt to pick up
|
||||
@@ -58,26 +70,283 @@ _DEFAULT_BACKEND = ChromaBackend()
|
||||
NORMALIZE_VERSION = 2
|
||||
|
||||
|
||||
# (palace_id, collection_name, model_name) tuples already validated this
|
||||
# process, so the identity check (one metadata read) runs at most once per
|
||||
# collection per run — keeps the hot get_collection path cheap.
|
||||
_VALIDATED_IDENTITY: set = set()
|
||||
|
||||
|
||||
def _enforce_embedder_identity(collection, palace_path, collection_name, *, create) -> None:
|
||||
"""Check (and, for a brand-new collection, record) embedder identity (RFC 001).
|
||||
|
||||
Check at open so a model swap fails fast — before any query silently
|
||||
returns degraded results. Record only when the collection is brand-new and
|
||||
empty: recording the *current* model on a legacy palace that already holds
|
||||
vectors from an unknown model would mislabel it, so populated-but-unrecorded
|
||||
collections warn instead and are resolved with
|
||||
``mempalace palace set-embedder``.
|
||||
|
||||
Bookkeeping must never break memory operations: only the deliberate
|
||||
identity/dimension mismatch propagates; every other error is swallowed.
|
||||
"""
|
||||
import warnings
|
||||
|
||||
from .backends.base import (
|
||||
DimensionMismatchError,
|
||||
EmbedderIdentity,
|
||||
EmbedderIdentityMismatchError,
|
||||
EmbedderIdentityUnknownWarning,
|
||||
check_embedder_identity,
|
||||
)
|
||||
from .embedding import current_model_name
|
||||
|
||||
# A server_embedder backend embeds with its own model and ignores the
|
||||
# injected/core embedder, so its effective identity — not the configured
|
||||
# model — is what must be checked and recorded. Fall back to the configured
|
||||
# model name for the normal (core-embedder) case.
|
||||
current: Optional[EmbedderIdentity] = None
|
||||
try:
|
||||
effective = collection.effective_embedder_identity()
|
||||
except Exception:
|
||||
effective = None
|
||||
if effective is not None and getattr(effective, "model_name", ""):
|
||||
current = effective
|
||||
else:
|
||||
try:
|
||||
model_name = current_model_name()
|
||||
except Exception:
|
||||
return
|
||||
if not model_name:
|
||||
return # nameless embedder — cannot enforce identity
|
||||
current = EmbedderIdentity(model_name=model_name, dimension=0)
|
||||
|
||||
model_name = current.model_name
|
||||
key = (str(palace_path), str(collection_name), model_name)
|
||||
if key in _VALIDATED_IDENTITY:
|
||||
return
|
||||
|
||||
try:
|
||||
stored = collection.get_stored_embedder_identity()
|
||||
except Exception:
|
||||
logger.debug("embedder-identity read failed for %s", collection_name, exc_info=True)
|
||||
return
|
||||
try:
|
||||
state = check_embedder_identity(stored, current)
|
||||
except (EmbedderIdentityMismatchError, DimensionMismatchError):
|
||||
raise # deliberate, user-facing — the whole point of the contract
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if state == "unknown" and stored is None:
|
||||
try:
|
||||
count = collection.count()
|
||||
except Exception:
|
||||
count = None
|
||||
if count == 0:
|
||||
if create:
|
||||
try:
|
||||
collection.set_embedder_identity(current)
|
||||
except Exception:
|
||||
logger.debug("embedder-identity record failed", exc_info=True)
|
||||
elif count:
|
||||
warnings.warn(
|
||||
f"palace collection {collection_name!r} has no recorded embedder "
|
||||
f"identity; assuming the current model {model_name!r}. Run "
|
||||
"`mempalace palace set-embedder --model <name>` to record it.",
|
||||
EmbedderIdentityUnknownWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
_VALIDATED_IDENTITY.add(key)
|
||||
|
||||
|
||||
def get_collection(
|
||||
palace_path: str,
|
||||
collection_name: Optional[str] = None,
|
||||
create: bool = True,
|
||||
backend: Optional[str] = None,
|
||||
_skip_identity_check: bool = False,
|
||||
):
|
||||
"""Get the palace collection through the backend layer."""
|
||||
"""Get the palace collection through the backend layer.
|
||||
|
||||
``_skip_identity_check`` bypasses the embedder-identity enforcement so the
|
||||
``set-embedder`` override path can open a palace whose recorded model
|
||||
differs from the current one (the very state it exists to repair).
|
||||
"""
|
||||
if collection_name is None:
|
||||
from .config import get_configured_collection_name
|
||||
|
||||
collection_name = get_configured_collection_name()
|
||||
return _DEFAULT_BACKEND.get_collection(
|
||||
backend_obj = get_backend_for_palace(palace_path, explicit=backend)
|
||||
palace_ref = PalaceRef(id=palace_path, local_path=palace_path)
|
||||
try:
|
||||
collection = backend_obj.get_collection(
|
||||
palace=palace_ref,
|
||||
collection_name=collection_name,
|
||||
create=create,
|
||||
)
|
||||
except TypeError as exc:
|
||||
if "unexpected keyword argument 'palace'" not in str(exc):
|
||||
raise
|
||||
collection = backend_obj.get_collection(
|
||||
palace_path,
|
||||
collection_name=collection_name,
|
||||
create=create,
|
||||
)
|
||||
if "requires_explicit_embeddings" in getattr(backend_obj, "capabilities", frozenset()):
|
||||
collection = EmbeddingCollection(collection)
|
||||
if not _skip_identity_check:
|
||||
_enforce_embedder_identity(collection, palace_path, collection_name, create=create)
|
||||
return collection
|
||||
|
||||
|
||||
def set_palace_embedder_identity(
|
||||
palace_path: str,
|
||||
model: Optional[str] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
backend: Optional[str] = None,
|
||||
collection_name: Optional[str] = None,
|
||||
):
|
||||
"""Record (or force-override) a palace collection's embedder identity (RFC 001).
|
||||
|
||||
Backs ``mempalace palace set-embedder``. Returns ``(old, new)`` identities.
|
||||
Without ``force``, refuses to overwrite an existing identity that names a
|
||||
different model (the user must confirm they know the vectors are
|
||||
compatible). Opens with the identity check skipped so a mismatched palace —
|
||||
the exact state being repaired — can be opened at all.
|
||||
"""
|
||||
from .backends.base import EmbedderIdentity, EmbedderIdentityMismatchError
|
||||
from .config import MempalaceConfig
|
||||
from .embedding import get_embedder_identity
|
||||
|
||||
configured = MempalaceConfig().embedding_model
|
||||
target = (model or configured or "").strip().lower()
|
||||
if not target:
|
||||
# No model given and none configured — there is nothing to record, and
|
||||
# recording a nameless identity is a silent no-op in every backend.
|
||||
raise ValueError(
|
||||
"no embedder model to record: pass --model NAME or configure MEMPALACE_EMBEDDING_MODEL"
|
||||
)
|
||||
if target == (configured or "").strip().lower():
|
||||
# Recording the in-use model — probe its dimension (already loaded).
|
||||
new = get_embedder_identity()
|
||||
else:
|
||||
# Explicit override of a non-configured model: record the name only,
|
||||
# never load a foreign model (which can be a large download) just to
|
||||
# probe a dimension. The model-name check is the actual protection.
|
||||
new = EmbedderIdentity(model_name=target, dimension=0)
|
||||
collection = get_collection(
|
||||
palace_path,
|
||||
collection_name=collection_name,
|
||||
create=True,
|
||||
backend=backend,
|
||||
_skip_identity_check=True,
|
||||
)
|
||||
try:
|
||||
old = collection.get_stored_embedder_identity()
|
||||
except Exception:
|
||||
old = None
|
||||
if old is not None and old.model_name != new.model_name and not force:
|
||||
raise EmbedderIdentityMismatchError(
|
||||
f"palace already records embedder {old.model_name!r}; pass --force to "
|
||||
f"overwrite it with {new.model_name!r} (only if the vectors are compatible)"
|
||||
)
|
||||
collection.set_embedder_identity(new)
|
||||
# Reset the per-process validation cache so a re-open re-checks against the
|
||||
# newly recorded identity rather than a stale verdict.
|
||||
_VALIDATED_IDENTITY.clear()
|
||||
return old, new
|
||||
|
||||
|
||||
def get_closets_collection(
|
||||
palace_path: str,
|
||||
create: bool = True,
|
||||
backend: Optional[str] = None,
|
||||
):
|
||||
"""Get the closets collection — the searchable index layer."""
|
||||
return get_collection(
|
||||
palace_path,
|
||||
collection_name="mempalace_closets",
|
||||
create=create,
|
||||
backend=backend,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
def _config_backend_value(palace_path: str) -> Optional[str]:
|
||||
try:
|
||||
from .config import MempalaceConfig
|
||||
|
||||
cfg = MempalaceConfig()
|
||||
cfg_palace = os.path.abspath(os.path.expanduser(cfg.palace_path))
|
||||
target_palace = os.path.abspath(os.path.expanduser(palace_path))
|
||||
if cfg_palace != target_palace:
|
||||
return None
|
||||
value = cfg._file_config.get("backend")
|
||||
return str(value).strip().lower() if value else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _env_backend_value() -> Optional[str]:
|
||||
value = os.environ.get("MEMPALACE_BACKEND")
|
||||
return value.strip().lower() if value else None
|
||||
|
||||
|
||||
def resolve_backend_name(palace_path: str, explicit: Optional[str] = None) -> str:
|
||||
"""Resolve and validate the selected backend for ``palace_path``.
|
||||
|
||||
Public resolution order:
|
||||
|
||||
1. Explicit CLI/MCP flag or direct ``get_collection(..., backend=...)``.
|
||||
2. ``backend`` in ``~/.mempalace/config.json``.
|
||||
3. ``MEMPALACE_BACKEND``.
|
||||
4. Detected existing palace artifacts.
|
||||
5. ``chroma``.
|
||||
|
||||
If artifacts for a different backend are already present, raise
|
||||
``BackendMismatchError`` so normal write paths cannot silently mix storage
|
||||
formats in one palace directory.
|
||||
"""
|
||||
explicit = explicit or os.environ.get(_EXPLICIT_BACKEND_ENV)
|
||||
selected = resolve_backend_for_palace(
|
||||
explicit=explicit.strip().lower() if explicit else None,
|
||||
config_value=_config_backend_value(palace_path),
|
||||
env_value=_env_backend_value(),
|
||||
palace_path=palace_path,
|
||||
default="chroma",
|
||||
)
|
||||
get_backend_class(selected)
|
||||
detected_backends = detect_backends_for_path(palace_path)
|
||||
if len(detected_backends) > 1:
|
||||
raise BackendMismatchError(
|
||||
f"palace at {palace_path!r} contains multiple backend artifacts: "
|
||||
f"{', '.join(detected_backends)}"
|
||||
)
|
||||
detected = detected_backends[0] if detected_backends else None
|
||||
if detected and detected != selected:
|
||||
raise BackendMismatchError(
|
||||
f"palace at {palace_path!r} contains {detected!r} backend artifacts, "
|
||||
f"but {selected!r} was selected"
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def get_backend_for_palace(palace_path: str, explicit: Optional[str] = None):
|
||||
"""Return the resolved backend instance for ``palace_path``."""
|
||||
return get_backend(resolve_backend_name(palace_path, explicit=explicit))
|
||||
|
||||
|
||||
def _backend_artifact_label(backend_name: Optional[str]) -> str:
|
||||
if backend_name == "chroma":
|
||||
return "chroma.sqlite3"
|
||||
if backend_name == "qdrant":
|
||||
return "qdrant_backend.json"
|
||||
if backend_name == "pgvector":
|
||||
return "pgvector_backend.json"
|
||||
if backend_name == "sqlite_exact":
|
||||
return "sqlite_exact.sqlite3"
|
||||
return "backend database"
|
||||
|
||||
|
||||
def _open_collection_or_explain(
|
||||
@@ -85,6 +354,7 @@ def _open_collection_or_explain(
|
||||
*,
|
||||
collection_name: Optional[str] = None,
|
||||
out=None,
|
||||
opener=None,
|
||||
):
|
||||
"""Open the palace collection or print a state-specific message and return ``None``.
|
||||
|
||||
@@ -101,11 +371,11 @@ def _open_collection_or_explain(
|
||||
first when the vector path is disabled (see PR #831 / issue #830).
|
||||
|
||||
State A: palace dir is absent.
|
||||
State B: dir is present but ``chroma.sqlite3`` is absent. The helper
|
||||
short-circuits to a message before reaching the backend, because
|
||||
``chromadb.PersistentClient`` lazily creates the DB file on first
|
||||
open — calling the backend on this state would silently mutate
|
||||
the filesystem for what should be a read-only inspection.
|
||||
State B: dir is present but no backend database artifact is present.
|
||||
The helper short-circuits to a message before reaching the backend,
|
||||
because some backends lazily create their DB file on first open —
|
||||
calling the backend on this state would silently mutate the filesystem
|
||||
for what should be a read-only inspection.
|
||||
State C: DB is present but the ``mempalace_drawers`` collection has
|
||||
never been bootstrapped (``init`` ran, ``mine`` has not).
|
||||
State D: healthy — returns the opened collection.
|
||||
@@ -116,17 +386,41 @@ def _open_collection_or_explain(
|
||||
callable (e.g. a repair progress emitter) to route messages through it.
|
||||
"""
|
||||
emit = out if out is not None else print
|
||||
open_collection = opener or get_collection
|
||||
|
||||
if not os.path.isdir(palace_path):
|
||||
emit(f"\n No palace found at {palace_path}")
|
||||
emit(" Run: mempalace init <dir> then mempalace mine <dir>")
|
||||
return None
|
||||
if not os.path.isfile(os.path.join(palace_path, "chroma.sqlite3")):
|
||||
emit(f"\n Palace dir at {palace_path} exists but has no chroma.sqlite3 yet.")
|
||||
try:
|
||||
backend_name = resolve_backend_name(palace_path)
|
||||
except BackendMismatchError as e:
|
||||
emit(f"\n Backend mismatch at {palace_path}: {e}")
|
||||
emit(" Select the matching backend or use a fresh palace directory.")
|
||||
return None
|
||||
except KeyError as e:
|
||||
# Unknown backend name (e.g. a typo in MEMPALACE_BACKEND/--backend):
|
||||
# resolve_backend_name -> get_backend_class raises KeyError carrying the
|
||||
# available-backend list. Surface it as a CLI state message rather than
|
||||
# letting it escape as a stack trace.
|
||||
emit(f"\n Unknown backend selected for {palace_path}: {e.args[0] if e.args else e}")
|
||||
emit(" Set --backend or MEMPALACE_BACKEND to a registered backend.")
|
||||
return None
|
||||
detected = detect_backend_for_path(palace_path)
|
||||
if detected is None:
|
||||
emit(
|
||||
f"\n Palace dir at {palace_path} exists but has no "
|
||||
f"{_backend_artifact_label(backend_name)} yet."
|
||||
)
|
||||
emit(" Run: mempalace mine <dir>")
|
||||
return None
|
||||
try:
|
||||
return get_collection(palace_path, collection_name=collection_name, create=False)
|
||||
return open_collection(
|
||||
palace_path,
|
||||
collection_name=collection_name,
|
||||
create=False,
|
||||
backend=backend_name,
|
||||
)
|
||||
except CollectionNotInitializedError:
|
||||
emit(f"\n Palace at {palace_path} is initialized but empty (no drawers yet).")
|
||||
emit(" Run: mempalace mine <dir>")
|
||||
@@ -135,6 +429,10 @@ def _open_collection_or_explain(
|
||||
emit(f"\n No palace found at {palace_path}")
|
||||
emit(" Run: mempalace init <dir> then mempalace mine <dir>")
|
||||
return None
|
||||
except BackendMismatchError as e:
|
||||
emit(f"\n Backend mismatch at {palace_path}: {e}")
|
||||
emit(" Select the matching backend or use a fresh palace directory.")
|
||||
return None
|
||||
except BackendClosedError:
|
||||
# Surface this as a programmer error, not a palace-state UX message:
|
||||
# a closed backend means the caller violated the backend lifecycle,
|
||||
@@ -265,9 +563,15 @@ def build_closet_lines(source_file, drawer_ids, content, wing, room, drawer_meta
|
||||
|
||||
# Extract proper nouns (2+ occurrences). Uses i18n-aware patterns so
|
||||
# non-Latin names (Cyrillic, accented Latin, etc.) are also detected.
|
||||
# Tier 3 linguistics cleanup — known-systems compound pre-pass. Detects
|
||||
# multi-word product names ("Claude Code", "GitHub Copilot", …) atomically
|
||||
# and masks them out of the working window so the single-word extraction
|
||||
# below doesn't decompose them.
|
||||
working_window, compound_counts = _apply_known_systems_prepass(window)
|
||||
|
||||
coca_filter = _get_coca_filter()
|
||||
words = _candidate_entity_words(window)
|
||||
word_freq = {}
|
||||
words = _candidate_entity_words(working_window)
|
||||
word_freq: dict = dict(compound_counts)
|
||||
for w in words:
|
||||
if w in _ENTITY_STOPLIST:
|
||||
continue
|
||||
@@ -421,38 +725,185 @@ def mine_lock(source_file: str):
|
||||
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")
|
||||
lock_path = _mine_lock_path(source_file)
|
||||
lf = _acquire_mine_lock_file(lock_path)
|
||||
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)
|
||||
_unlock_mine_lock_file(lf)
|
||||
except Exception:
|
||||
logger.debug("Mine-lock release failed", exc_info=True)
|
||||
try:
|
||||
lf.close()
|
||||
except Exception:
|
||||
logger.debug("Mine-lock close failed", exc_info=True)
|
||||
_cleanup_mine_lock_file(lock_path)
|
||||
|
||||
|
||||
def _mine_lock_path(source_file: str) -> str:
|
||||
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
|
||||
os.makedirs(lock_dir, exist_ok=True)
|
||||
return os.path.join(lock_dir, hashlib.sha256(source_file.encode()).hexdigest()[:16] + ".lock")
|
||||
|
||||
|
||||
def _open_mine_lock_file(lock_path: str, *, create: bool):
|
||||
flags = os.O_RDWR
|
||||
if create:
|
||||
flags |= os.O_CREAT
|
||||
fd = os.open(lock_path, flags, 0o600)
|
||||
return os.fdopen(fd, "r+b")
|
||||
|
||||
|
||||
def _lock_mine_lock_file(lock_file, *, blocking: bool) -> bool:
|
||||
lock_file.seek(0)
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
|
||||
try:
|
||||
msvcrt.locking(lock_file.fileno(), mode, 1)
|
||||
except OSError:
|
||||
if not blocking:
|
||||
return False
|
||||
raise
|
||||
return True
|
||||
|
||||
import fcntl
|
||||
|
||||
flags = fcntl.LOCK_EX
|
||||
if not blocking:
|
||||
flags |= fcntl.LOCK_NB
|
||||
try:
|
||||
fcntl.flock(lock_file, flags)
|
||||
except BlockingIOError:
|
||||
if not blocking:
|
||||
return False
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
def _unlock_mine_lock_file(lock_file) -> None:
|
||||
lock_file.seek(0)
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
return
|
||||
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _mine_lock_file_is_current(lock_file, lock_path: str) -> bool:
|
||||
"""Return whether ``lock_file`` is still the inode reached by ``lock_path``.
|
||||
|
||||
POSIX advisory locks attach to the opened inode, not the pathname. If a
|
||||
lock file is unlinked while a contender is waiting, that contender can later
|
||||
acquire a lock on an inode no new process will use. We reject that stale
|
||||
handle and retry on the current pathname.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return True
|
||||
try:
|
||||
path_stat = os.stat(lock_path)
|
||||
file_stat = os.fstat(lock_file.fileno())
|
||||
except OSError:
|
||||
return False
|
||||
return (path_stat.st_dev, path_stat.st_ino) == (file_stat.st_dev, file_stat.st_ino)
|
||||
|
||||
|
||||
def _acquire_open_mine_lock_file(lock_file, lock_path: str) -> bool:
|
||||
"""Acquire ``lock_file`` and return False if cleanup made it stale."""
|
||||
_lock_mine_lock_file(lock_file, blocking=True)
|
||||
if _mine_lock_file_is_current(lock_file, lock_path):
|
||||
return True
|
||||
try:
|
||||
_unlock_mine_lock_file(lock_file)
|
||||
except Exception:
|
||||
logger.debug("Mine-lock stale-handle release failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _acquire_mine_lock_file(lock_path: str):
|
||||
while True:
|
||||
lf = _open_mine_lock_file(lock_path, create=True)
|
||||
try:
|
||||
if _acquire_open_mine_lock_file(lf, lock_path):
|
||||
return lf
|
||||
except Exception:
|
||||
lf.close()
|
||||
raise
|
||||
lf.close()
|
||||
|
||||
|
||||
def _cleanup_mine_lock_file(lock_path: str) -> None:
|
||||
"""Best-effort removal that preserves flock rendezvous semantics.
|
||||
|
||||
A plain ``os.remove(lock_path)`` after closing the critical-section lock is
|
||||
unsafe on POSIX: a waiter may already be blocked on the old inode while a
|
||||
later process creates and locks a new inode at the same pathname. Instead,
|
||||
cleanup briefly re-acquires the current file nonblocking. If it wins, it can
|
||||
unlink that inode as cleanup-only work; waiters on the old inode will detect
|
||||
the stale handle after waking and retry on the current path.
|
||||
"""
|
||||
try:
|
||||
lf = _open_mine_lock_file(lock_path, create=False)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
logger.debug("Mine-lock cleanup open failed for %s", lock_path, exc_info=True)
|
||||
return
|
||||
|
||||
acquired = False
|
||||
closed = False
|
||||
try:
|
||||
try:
|
||||
acquired = _lock_mine_lock_file(lf, blocking=False)
|
||||
except OSError:
|
||||
logger.debug("Mine-lock cleanup acquire failed for %s", lock_path, exc_info=True)
|
||||
return
|
||||
if not acquired:
|
||||
return
|
||||
if not _mine_lock_file_is_current(lf, lock_path):
|
||||
return
|
||||
|
||||
if os.name == "nt":
|
||||
# Windows generally cannot unlink an open locked file. Release and
|
||||
# close first; if another process opens the file in the gap,
|
||||
# os.remove should fail and we leave the rendezvous file in place.
|
||||
try:
|
||||
_unlock_mine_lock_file(lf)
|
||||
except Exception:
|
||||
logger.debug("Mine-lock cleanup release failed", exc_info=True)
|
||||
acquired = False
|
||||
return
|
||||
acquired = False
|
||||
lf.close()
|
||||
closed = True
|
||||
try:
|
||||
os.remove(lock_path)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
os.remove(lock_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
logger.debug("Mine-lock cleanup remove failed for %s", lock_path, exc_info=True)
|
||||
finally:
|
||||
if not closed:
|
||||
if acquired:
|
||||
try:
|
||||
_unlock_mine_lock_file(lf)
|
||||
except Exception:
|
||||
logger.debug("Mine-lock cleanup release failed", exc_info=True)
|
||||
lf.close()
|
||||
|
||||
|
||||
class MineAlreadyRunning(RuntimeError):
|
||||
"""Raised when another `mempalace mine` already holds the per-palace lock."""
|
||||
|
||||
@@ -478,6 +929,9 @@ def _validate_palace_fts5_after_mine(palace_path: str) -> None:
|
||||
operator sees the same recovery banner regardless of which command surfaces
|
||||
the bug.
|
||||
"""
|
||||
if resolve_backend_name(palace_path) != "chroma":
|
||||
return
|
||||
|
||||
# Defer-import: keeps the repair module graph out of mine's hot import path.
|
||||
from .repair import _close_chroma_handles, sqlite_integrity_errors
|
||||
|
||||
@@ -735,47 +1189,51 @@ def file_already_mined(
|
||||
treated as exchange-mode drawers.
|
||||
"""
|
||||
try:
|
||||
stored_meta = None
|
||||
if extract_mode is None:
|
||||
results = collection.get(where={"source_file": source_file}, limit=1)
|
||||
if not results.get("ids"):
|
||||
return False
|
||||
stored_meta = results.get("metadatas", [{}])[0] or {}
|
||||
else:
|
||||
offset = 0
|
||||
while True:
|
||||
results = collection.get(
|
||||
where={"source_file": source_file},
|
||||
limit=1000,
|
||||
offset=offset,
|
||||
include=["metadatas"],
|
||||
)
|
||||
ids = results.get("ids") or []
|
||||
metadatas = results.get("metadatas") or []
|
||||
stored_meta = next(
|
||||
(
|
||||
meta or {}
|
||||
for meta in metadatas
|
||||
if _metadata_matches_extract_mode(meta or {}, extract_mode)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if stored_meta is not None or not ids:
|
||||
break
|
||||
offset += len(ids)
|
||||
if stored_meta is None:
|
||||
return False
|
||||
# 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_mtime = stored_meta.get("source_mtime")
|
||||
if stored_mtime is None:
|
||||
return False
|
||||
current_mtime = os.path.getmtime(source_file)
|
||||
return abs(float(stored_mtime) - current_mtime) < 0.001
|
||||
return True
|
||||
# Under the additive-mining model, a single ``source_file`` can have
|
||||
# multiple ``parent_drawer_id`` groups in the palace — one per
|
||||
# mining pass — each with its own stored ``source_mtime`` and
|
||||
# ``normalize_version``. The function must return True if ANY stored
|
||||
# group is current (matching version + matching mtime when checked),
|
||||
# because ChromaDB's ``get(..., limit=1)`` has undefined ordering
|
||||
# across multiple matching rows: a ``limit=1`` shortcut picks
|
||||
# whichever row ChromaDB orders first and only checks that one,
|
||||
# causing spurious re-mines whenever the stale group is returned.
|
||||
# Iterating via the same paginated pattern used in the
|
||||
# extract_mode-is-set branch lets the function short-circuit on the
|
||||
# first matching group regardless of ordering.
|
||||
current_mtime = os.path.getmtime(source_file) if check_mtime else None
|
||||
offset = 0
|
||||
while True:
|
||||
results = collection.get(
|
||||
where={"source_file": source_file},
|
||||
limit=1000,
|
||||
offset=offset,
|
||||
include=["metadatas"],
|
||||
)
|
||||
ids = results.get("ids") or []
|
||||
metadatas = results.get("metadatas") or []
|
||||
for meta in metadatas:
|
||||
meta = meta or {}
|
||||
# extract_mode scoping (was the existing ``else`` branch):
|
||||
if extract_mode is not None and not _metadata_matches_extract_mode(
|
||||
meta, extract_mode
|
||||
):
|
||||
continue
|
||||
# Pre-v2 drawers have no version field — treat them as stale.
|
||||
stored_version = meta.get("normalize_version", 1)
|
||||
if stored_version < NORMALIZE_VERSION:
|
||||
continue
|
||||
if not check_mtime:
|
||||
return True
|
||||
stored_mtime = meta.get("source_mtime")
|
||||
if stored_mtime is None:
|
||||
continue
|
||||
if abs(float(stored_mtime) - current_mtime) < 0.001:
|
||||
return True
|
||||
if not ids:
|
||||
break
|
||||
offset += len(ids)
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
+28
-2
@@ -715,6 +715,19 @@ def _vacuum_and_rebuild_fts5(palace_path: str, progress=print) -> None:
|
||||
progress(f" Warning: post-repair cleanup failed (non-fatal): {exc}")
|
||||
|
||||
|
||||
def _post_rebuild_cleanup(palace_path: str, backend: "ChromaBackend", progress=print) -> None:
|
||||
"""Close cached chroma handles, then VACUUM and rebuild the FTS5 index.
|
||||
|
||||
Shared epilogue for the two full-rebuild paths (``rebuild_index`` and the
|
||||
CLI legacy ``cmd_repair``), so neither can drift out of the post-run
|
||||
cleanup again (issues #1517, #1747). ChromaDB's PersistentClient keeps
|
||||
chroma.sqlite3 open and VACUUM needs exclusive access, so the handles
|
||||
are released first.
|
||||
"""
|
||||
_close_chroma_handles(palace_path, backend=backend)
|
||||
_vacuum_and_rebuild_fts5(palace_path, progress=progress)
|
||||
|
||||
|
||||
def rebuild_index(
|
||||
palace_path=None,
|
||||
confirm_truncation_ok: bool = False,
|
||||
@@ -848,8 +861,7 @@ def rebuild_index(
|
||||
print(" Live collection was not replaced; leaving the original palace untouched.")
|
||||
raise
|
||||
|
||||
_close_chroma_handles(palace_path, backend=backend)
|
||||
_vacuum_and_rebuild_fts5(palace_path, progress=progress)
|
||||
_post_rebuild_cleanup(palace_path, backend=backend, progress=progress)
|
||||
|
||||
print(f"\n Repair complete. {filed} drawers rebuilt.")
|
||||
print(" HNSW index is now clean with cosine distance metric.")
|
||||
@@ -1529,12 +1541,26 @@ def repair_max_seq_id(
|
||||
return result
|
||||
|
||||
if backup:
|
||||
import glob
|
||||
|
||||
from .backups import prune_backups
|
||||
from .config import MempalaceConfig
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = os.path.join(palace_path, f"chroma.sqlite3.max-seq-id-backup-{timestamp}")
|
||||
shutil.copy2(db_path, backup_path)
|
||||
result["backup"] = backup_path
|
||||
print(f" Backup: {backup_path}")
|
||||
|
||||
# Retain only the most recent N backups (the copy just written is the
|
||||
# newest and is kept). Without this, every max-seq-id repair leaves a
|
||||
# full chroma.sqlite3 copy behind that is never cleaned up.
|
||||
prune_backups(
|
||||
os.path.join(glob.escape(palace_path), "chroma.sqlite3.max-seq-id-backup-*"),
|
||||
MempalaceConfig().max_backups,
|
||||
log=print,
|
||||
)
|
||||
|
||||
_close_chroma_handles(palace_path)
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
|
||||
+347
-101
@@ -16,8 +16,19 @@ import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from .backends import CollectionNotInitializedError, PalaceNotFoundError
|
||||
from .palace import get_closets_collection, get_collection
|
||||
from .backends import (
|
||||
BackendError,
|
||||
BackendMismatchError,
|
||||
CollectionNotInitializedError,
|
||||
PalaceNotFoundError,
|
||||
UnsupportedCapabilityError,
|
||||
)
|
||||
from .palace import (
|
||||
_open_collection_or_explain,
|
||||
get_closets_collection,
|
||||
get_collection,
|
||||
resolve_backend_name,
|
||||
)
|
||||
|
||||
# Closet pointer line format: "topic|entities|→drawer_id_a,drawer_id_b"
|
||||
# Multiple lines may join with newlines inside one closet document.
|
||||
@@ -119,18 +130,70 @@ def _bm25_scores(
|
||||
return scores
|
||||
|
||||
|
||||
def _distance_to_similarity(distance, metric: str = "cosine") -> float:
|
||||
"""Map a backend-reported ``distance`` to a [0, 1]-ish similarity.
|
||||
|
||||
The backend contract for the ``distances`` field is *lower = closer*
|
||||
regardless of metric (RFC 001, backend metric declaration), so every
|
||||
mapping here is monotonic decreasing in ``distance``. The output stays
|
||||
bounded so it is
|
||||
commensurable with the min-max-normalized BM25 term in
|
||||
:func:`_hybrid_rank`.
|
||||
|
||||
* ``cosine`` — distance ∈ [0, 2], 0 = identical: ``max(0, 1 - d)``.
|
||||
* ``l2`` — Euclidean ∈ [0, ∞): ``1 / (1 + d)`` (1 at d=0, →0 as d→∞).
|
||||
* ``ip`` — inner-product distance (e.g. pgvector ``<#>`` = -dot, lower =
|
||||
closer), unbounded and signed: logistic squash ``1 / (1 + e^d)``.
|
||||
Provisional until a real ip backend exercises it; no in-tree backend
|
||||
uses ip today.
|
||||
|
||||
``distance is None`` (vector-unknown, e.g. a BM25-only candidate) maps to
|
||||
0.0 so the candidate scores on its BM25 contribution alone.
|
||||
"""
|
||||
if distance is None:
|
||||
return 0.0
|
||||
m = (metric or "cosine").lower()
|
||||
if m == "l2":
|
||||
return 1.0 / (1.0 + max(0.0, distance))
|
||||
if m == "ip":
|
||||
# Clamp the exponent so a large positive distance can't overflow.
|
||||
return 1.0 / (1.0 + math.exp(min(60.0, distance)))
|
||||
# cosine (default)
|
||||
return max(0.0, 1.0 - distance)
|
||||
|
||||
|
||||
def _metric_for_collection(col) -> str:
|
||||
"""Resolve a collection's declared distance metric, defaulting to cosine.
|
||||
|
||||
Reads the ``distance_metric`` exposed by the backend collection (the
|
||||
RFC 001 backend metric declaration). ``EmbeddingCollection`` delegates the
|
||||
attribute to its inner collection; legacy Chroma palaces report their
|
||||
actual ``hnsw:space``.
|
||||
Any failure falls back to ``"cosine"`` — the value all in-tree backends
|
||||
use and the only metric MemPalace created palaces with historically.
|
||||
"""
|
||||
try:
|
||||
metric = getattr(col, "distance_metric", "cosine")
|
||||
except Exception:
|
||||
return "cosine"
|
||||
metric = str(metric or "cosine").lower()
|
||||
return metric if metric in ("cosine", "l2", "ip") else "cosine"
|
||||
|
||||
|
||||
def _hybrid_rank(
|
||||
results: list,
|
||||
query: str,
|
||||
vector_weight: float = 0.6,
|
||||
bm25_weight: float = 0.4,
|
||||
metric: str = "cosine",
|
||||
) -> 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.
|
||||
* Vector similarity is derived from each candidate's backend-reported
|
||||
``distance`` via :func:`_distance_to_similarity`, interpreted in the
|
||||
collection's declared ``metric`` (per RFC 001) rather than assuming
|
||||
cosine. 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.
|
||||
@@ -153,11 +216,7 @@ def _hybrid_rank(
|
||||
|
||||
scored = []
|
||||
for r, raw, norm in zip(results, bm25_raw, bm25_norm):
|
||||
distance = r.get("distance")
|
||||
if distance is None:
|
||||
vec_sim = 0.0
|
||||
else:
|
||||
vec_sim = max(0.0, 1.0 - distance)
|
||||
vec_sim = _distance_to_similarity(r.get("distance"), metric)
|
||||
r["bm25_score"] = round(raw, 3)
|
||||
scored.append((vector_weight * vec_sim + bm25_weight * norm, r))
|
||||
|
||||
@@ -191,6 +250,31 @@ def _extract_drawer_ids_from_closet(closet_doc: str) -> list:
|
||||
return list(seen.keys())
|
||||
|
||||
|
||||
def _scoped_source_filter(source_file: str, parent_drawer_id=None) -> dict:
|
||||
"""Build a Chroma ``where`` clause that scopes a query to ``source_file``,
|
||||
additionally constrained by ``parent_drawer_id`` when one is supplied.
|
||||
|
||||
Two unrelated oversized ``tool_add_drawer`` writes (chunked path from
|
||||
#1539) can pass the same ``source_file`` (e.g. two pastes tagged
|
||||
``"chat.log"``); each call stores its own ``parent_drawer_id`` group
|
||||
of chunks but the bare ``source_file`` filter pulls chunks from both
|
||||
groups as if they were siblings (#1580). When the matched chunk
|
||||
carries a ``parent_drawer_id`` the filter narrows to that logical
|
||||
group. Otherwise (pre-#1539 drawers, single-chunk writes, and
|
||||
``diary_ingest`` drawers grouped by real file path) the original
|
||||
file-global shape is preserved. Mirrors the conditional-``$and``
|
||||
precedent in ``build_where_filter``.
|
||||
"""
|
||||
if parent_drawer_id:
|
||||
return {
|
||||
"$and": [
|
||||
{"source_file": source_file},
|
||||
{"parent_drawer_id": parent_drawer_id},
|
||||
]
|
||||
}
|
||||
return {"source_file": source_file}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -214,15 +298,20 @@ def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, ra
|
||||
if not src or not isinstance(chunk_idx, int):
|
||||
return {"text": matched_doc, "drawer_index": chunk_idx, "total_drawers": None}
|
||||
|
||||
# Narrow by ``parent_drawer_id`` when present so chunks from unrelated
|
||||
# logical drawers sharing ``source_file`` do not stitch (#1580). See
|
||||
# ``_scoped_source_filter`` for the contract.
|
||||
parent_id = matched_meta.get("parent_drawer_id")
|
||||
target_indexes = [chunk_idx + offset for offset in range(-radius, radius + 1)]
|
||||
neighbor_clauses = [
|
||||
{"source_file": src},
|
||||
{"chunk_index": {"$in": target_indexes}},
|
||||
]
|
||||
if parent_id:
|
||||
neighbor_clauses.append({"parent_drawer_id": parent_id})
|
||||
try:
|
||||
neighbors = drawers_col.get(
|
||||
where={
|
||||
"$and": [
|
||||
{"source_file": src},
|
||||
{"chunk_index": {"$in": target_indexes}},
|
||||
]
|
||||
},
|
||||
where={"$and": neighbor_clauses},
|
||||
include=["documents", "metadatas"],
|
||||
)
|
||||
except Exception:
|
||||
@@ -240,10 +329,16 @@ def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, ra
|
||||
else:
|
||||
combined_text = "\n\n".join(doc for _, doc in indexed_docs)
|
||||
|
||||
# Cheap total_drawers lookup: metadata-only scan of the source file.
|
||||
# Cheap total_drawers lookup. When ``parent_drawer_id`` is present the
|
||||
# count is scoped to that group so the returned number matches the
|
||||
# text the caller gets back. Without a parent id, the legacy
|
||||
# file-global count is preserved.
|
||||
total_drawers = None
|
||||
try:
|
||||
all_meta = drawers_col.get(where={"source_file": src}, include=["metadatas"])
|
||||
all_meta = drawers_col.get(
|
||||
where=_scoped_source_filter(src, parent_id),
|
||||
include=["metadatas"],
|
||||
)
|
||||
total_drawers = len(all_meta.ids) if all_meta.ids else None
|
||||
except Exception:
|
||||
logger.debug("total_drawers lookup failed for %s", src, exc_info=True)
|
||||
@@ -296,32 +391,11 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
|
||||
Search the palace. Returns verbatim drawer content.
|
||||
Optionally filter by wing (project) or room (aspect).
|
||||
"""
|
||||
# Filesystem-first checks distinguish State A / State B before reaching
|
||||
# chromadb. PersistentClient lazily creates chroma.sqlite3 on first open
|
||||
# of an empty palace dir, so without these checks State B collapses into
|
||||
# the "initialized but empty" State C message and mutates the dir as a
|
||||
# side effect of a read-only search call (#1498).
|
||||
if not os.path.isdir(palace_path):
|
||||
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}")
|
||||
if not os.path.isfile(os.path.join(palace_path, "chroma.sqlite3")):
|
||||
print(f"\n Palace dir at {palace_path} exists but has no chroma.sqlite3 yet.")
|
||||
print(" Run: mempalace mine <dir>")
|
||||
col = _open_collection_or_explain(palace_path, opener=get_collection)
|
||||
if col is None:
|
||||
if not os.path.isdir(palace_path):
|
||||
raise SearchError(f"No palace found at {palace_path}")
|
||||
raise SearchError(f"No palace database at {palace_path}")
|
||||
try:
|
||||
col = get_collection(palace_path, create=False)
|
||||
except CollectionNotInitializedError as e:
|
||||
# State C from #1498: palace initialized but never mined.
|
||||
print(f"\n Palace at {palace_path} is initialized but empty (no drawers yet).")
|
||||
print(" Run: mempalace mine <dir>")
|
||||
raise SearchError(f"Palace at {palace_path} is initialized but empty") from e
|
||||
except PalaceNotFoundError as e:
|
||||
# Backend filesystem-race fallback: dir was deleted between our
|
||||
# check above and the backend call. Same message as State A.
|
||||
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}") from e
|
||||
|
||||
# Alert the user if this palace predates hnsw:space=cosine being set on
|
||||
# creation — their similarity scores will be junk until they run repair.
|
||||
@@ -360,11 +434,12 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
|
||||
# The MCP tool path already hybridizes BM25 with vector sim via
|
||||
# `_hybrid_rank`; do the same here so CLI results match what agents
|
||||
# see via `mempalace_search`.
|
||||
metric = _metric_for_collection(col)
|
||||
hits = [
|
||||
{"text": doc or "", "distance": float(dist), "metadata": meta or {}}
|
||||
for doc, meta, dist in zip(docs, metas, dists)
|
||||
]
|
||||
hits = _hybrid_rank(hits, query)
|
||||
hits = _hybrid_rank(hits, query, metric=metric)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f' Results for: "{query}"')
|
||||
@@ -375,7 +450,7 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
for i, hit in enumerate(hits, 1):
|
||||
vec_sim = round(max(0.0, 1 - hit["distance"]), 3)
|
||||
vec_sim = round(_distance_to_similarity(hit["distance"], metric), 3)
|
||||
bm25 = hit.get("bm25_score", 0.0)
|
||||
meta = hit["metadata"]
|
||||
source = Path(meta.get("source_file", "?")).name
|
||||
@@ -384,7 +459,7 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
|
||||
|
||||
print(f" [{i}] {wing_name} / {room_name}")
|
||||
print(f" Source: {source}")
|
||||
print(f" Match: cosine={vec_sim} bm25={bm25}")
|
||||
print(f" Match: {metric}_sim={vec_sim} bm25={bm25}")
|
||||
print()
|
||||
# Print the verbatim text, indented
|
||||
for line in hit["text"].strip().split("\n"):
|
||||
@@ -636,14 +711,14 @@ def _bm25_only_via_sqlite(
|
||||
|
||||
def _merge_bm25_union_candidates(
|
||||
hits: list,
|
||||
drawers_col,
|
||||
query: str,
|
||||
palace_path: str,
|
||||
wing: str,
|
||||
room: str,
|
||||
n_results: int,
|
||||
max_distance: float = 0.0,
|
||||
) -> None:
|
||||
"""Append top-K BM25-only candidates from sqlite into ``hits`` in place.
|
||||
"""Append top-K backend lexical candidates into ``hits`` in place.
|
||||
|
||||
Used by ``search_memories(..., candidate_strategy="union")`` to widen
|
||||
the rerank pool's *source* (not just its size) — vector-only candidate
|
||||
@@ -668,19 +743,41 @@ def _merge_bm25_union_candidates(
|
||||
if max_distance > 0.0:
|
||||
return
|
||||
|
||||
where = build_where_filter(wing, room)
|
||||
try:
|
||||
bm25_extra = _bm25_only_via_sqlite(
|
||||
query,
|
||||
palace_path,
|
||||
wing=wing,
|
||||
room=room,
|
||||
lexical = drawers_col.lexical_search(
|
||||
query=query,
|
||||
n_results=n_results * 3,
|
||||
_include_internal=True,
|
||||
).get("results", [])
|
||||
where=where or None,
|
||||
)
|
||||
except UnsupportedCapabilityError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.debug("candidate_strategy=union: BM25 fetch failed", exc_info=True)
|
||||
logger.debug("candidate_strategy=union: lexical fetch failed", exc_info=True)
|
||||
return
|
||||
|
||||
bm25_extra = []
|
||||
for hit in lexical.hits:
|
||||
meta = hit.metadata or {}
|
||||
full_source = meta.get("source_file", "") or ""
|
||||
bm25_extra.append(
|
||||
{
|
||||
"text": hit.document or "",
|
||||
"wing": meta.get("wing", "unknown"),
|
||||
"room": meta.get("room", "unknown"),
|
||||
"source_file": Path(full_source).name if full_source else "?",
|
||||
"created_at": meta.get("filed_at", "unknown"),
|
||||
"similarity": None,
|
||||
"distance": None,
|
||||
"effective_distance": None,
|
||||
"closet_boost": 0.0,
|
||||
"matched_via": "bm25_backend",
|
||||
"bm25_score": round(float(hit.score), 3),
|
||||
"_source_file_full": full_source,
|
||||
"_chunk_index": meta.get("chunk_index"),
|
||||
}
|
||||
)
|
||||
|
||||
def _dedup_key(entry: dict):
|
||||
full = entry.get("_source_file_full")
|
||||
ci = entry.get("_chunk_index")
|
||||
@@ -728,8 +825,8 @@ def _validate_candidate_strategy(strategy: str) -> None:
|
||||
def _apply_candidate_strategy(
|
||||
strategy: str,
|
||||
hits: list,
|
||||
drawers_col,
|
||||
query: str,
|
||||
palace_path: str,
|
||||
wing: str,
|
||||
room: str,
|
||||
n_results: int,
|
||||
@@ -742,7 +839,164 @@ def _apply_candidate_strategy(
|
||||
"""
|
||||
merger = _CANDIDATE_MERGERS[strategy]
|
||||
if merger is not None:
|
||||
merger(hits, query, palace_path, wing, room, n_results, max_distance=max_distance)
|
||||
merger(hits, drawers_col, query, wing, room, n_results, max_distance=max_distance)
|
||||
|
||||
|
||||
def _finalize_candidate_hits(
|
||||
*,
|
||||
candidate_strategy: str,
|
||||
hits: list,
|
||||
drawers_col,
|
||||
query: str,
|
||||
wing: str,
|
||||
room: str,
|
||||
n_results: int,
|
||||
max_distance: float,
|
||||
) -> tuple:
|
||||
try:
|
||||
_apply_candidate_strategy(
|
||||
candidate_strategy,
|
||||
hits,
|
||||
drawers_col,
|
||||
query,
|
||||
wing,
|
||||
room,
|
||||
n_results,
|
||||
max_distance=max_distance,
|
||||
)
|
||||
except UnsupportedCapabilityError:
|
||||
return [], {
|
||||
"error": "candidate_strategy='union' requires a backend with lexical_search support",
|
||||
"unsupported_capability": "supports_lexical_search",
|
||||
"hint": "Use candidate_strategy='vector' or select a backend that supports lexical search.",
|
||||
}
|
||||
|
||||
hits = _hybrid_rank(hits, query, metric=_metric_for_collection(drawers_col))[:n_results]
|
||||
for h in hits:
|
||||
h.pop("_sort_key", None)
|
||||
h.pop("_source_file_full", None)
|
||||
h.pop("_chunk_index", None)
|
||||
h.pop("_parent_drawer_id", None)
|
||||
return hits, None
|
||||
|
||||
|
||||
def _backend_mismatch_result(error: BackendMismatchError) -> dict:
|
||||
return {
|
||||
"error": "Backend mismatch",
|
||||
"details": str(error),
|
||||
"hint": "Select the matching backend or use a fresh palace directory.",
|
||||
}
|
||||
|
||||
|
||||
def _unknown_backend_result(error: KeyError) -> dict:
|
||||
return {
|
||||
"error": "Unknown backend",
|
||||
"details": str(error),
|
||||
"hint": "Check MEMPALACE_BACKEND or the configured backend name.",
|
||||
}
|
||||
|
||||
|
||||
def _vector_disabled_search(
|
||||
*,
|
||||
query: str,
|
||||
palace_path: str,
|
||||
wing: str,
|
||||
room: str,
|
||||
n_results: int,
|
||||
collection_name: str,
|
||||
) -> dict:
|
||||
try:
|
||||
backend_name = resolve_backend_name(palace_path)
|
||||
except BackendMismatchError as e:
|
||||
return _backend_mismatch_result(e)
|
||||
except KeyError as e:
|
||||
return _unknown_backend_result(e)
|
||||
if backend_name != "chroma":
|
||||
return {
|
||||
"error": "vector_disabled fallback is Chroma-only",
|
||||
"unsupported_capability": "chroma_hnsw_fallback",
|
||||
"backend": backend_name,
|
||||
"hint": "Disable vector_disabled for non-Chroma backends.",
|
||||
}
|
||||
return _bm25_only_via_sqlite(
|
||||
query,
|
||||
palace_path,
|
||||
wing=wing,
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
|
||||
def _open_search_collection(palace_path: str, collection_name: str):
|
||||
try:
|
||||
return get_collection(palace_path, collection_name=collection_name, create=False), None
|
||||
except BackendMismatchError as e:
|
||||
return None, _backend_mismatch_result(e)
|
||||
except KeyError as e:
|
||||
return None, _unknown_backend_result(e)
|
||||
except (CollectionNotInitializedError, PalaceNotFoundError) as e:
|
||||
logger.error("No palace found at %s: %s", palace_path, e)
|
||||
return None, {
|
||||
"error": "No palace found",
|
||||
"hint": "Run: mempalace init <dir> && mempalace mine <dir>",
|
||||
}
|
||||
except BackendError as e:
|
||||
logger.error("Backend error opening palace at %s: %s", palace_path, e)
|
||||
return None, {
|
||||
"error": "Backend error",
|
||||
"details": str(e),
|
||||
"hint": "Check the selected backend configuration and availability.",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("No palace found at %s: %s", palace_path, e)
|
||||
return None, {
|
||||
"error": "No palace found",
|
||||
"hint": "Run: mempalace init <dir> && mempalace mine <dir>",
|
||||
}
|
||||
|
||||
|
||||
def _query_drawers_with_filter_fallback(drawers_col, dkwargs, query, n_results, wing, room):
|
||||
"""Run the filtered drawer query, falling back to an unfiltered query plus a
|
||||
Python-side post-filter when ChromaDB raises on the filtered query.
|
||||
|
||||
A ChromaDB HNSW/SQLite index mismatch makes filtered queries fail with
|
||||
"Error finding id" even when unfiltered search works fine — it happens when
|
||||
drawers are ingested via two different paths (e.g. bulk import vs MCP tool
|
||||
calls), leaving the vector index inconsistent with the metadata store. We
|
||||
retry unfiltered (over-fetching) and re-apply the wing/room filter in Python.
|
||||
See #1245 / #1035.
|
||||
"""
|
||||
where = dkwargs.get("where")
|
||||
try:
|
||||
return drawers_col.query(**dkwargs)
|
||||
except Exception as filter_err:
|
||||
if not where:
|
||||
raise
|
||||
logger.warning(
|
||||
"Filtered search failed (%s); falling back to unfiltered + post-filter",
|
||||
filter_err,
|
||||
)
|
||||
raw = drawers_col.query(
|
||||
query_texts=[query],
|
||||
n_results=min(n_results * 15, 500),
|
||||
include=["documents", "metadatas", "distances"],
|
||||
)
|
||||
fdocs, fmetas, fdists = [], [], []
|
||||
for doc, meta, dist in zip(
|
||||
_first_or_empty(raw, "documents"),
|
||||
_first_or_empty(raw, "metadatas"),
|
||||
_first_or_empty(raw, "distances"),
|
||||
):
|
||||
meta = meta or {}
|
||||
if wing and meta.get("wing") != wing:
|
||||
continue
|
||||
if room and meta.get("room") != room:
|
||||
continue
|
||||
fdocs.append(doc)
|
||||
fmetas.append(meta)
|
||||
fdists.append(dist)
|
||||
return {"documents": [fdocs], "metadatas": [fmetas], "distances": [fdists]}
|
||||
|
||||
|
||||
def search_memories(
|
||||
@@ -780,14 +1034,12 @@ def search_memories(
|
||||
``n_results * 3`` rows from the vector index are the rerank pool.
|
||||
Cheap; works well when query and target docs agree in the
|
||||
embedding space.
|
||||
* ``"union"`` — also pull top ``n_results * 3`` BM25 candidates
|
||||
from the sqlite FTS5 index and merge them into the rerank pool
|
||||
(deduped by source_file). Catches docs with strong BM25 signal
|
||||
that are vector-distant from the query (e.g. terminology guides
|
||||
looked up by narrative-shaped queries; policy clauses surfaced
|
||||
by scenario descriptions). Adds one sqlite open + FTS5 MATCH
|
||||
per query; perf cost is small but unmeasured at corpus scale.
|
||||
Opt in until the cost is characterized.
|
||||
* ``"union"`` — also pull top ``n_results * 3`` lexical candidates
|
||||
through the backend's ``lexical_search`` capability and merge
|
||||
them into the rerank pool (deduped by source_file). Catches docs
|
||||
with strong BM25 signal that are vector-distant from the query.
|
||||
Perf depends on the selected backend; opt in until the cost is
|
||||
characterized.
|
||||
|
||||
When ``max_distance > 0.0`` is also set, BM25-only candidates
|
||||
are skipped — they have no vector distance and would silently
|
||||
@@ -799,24 +1051,20 @@ def search_memories(
|
||||
_validate_candidate_strategy(candidate_strategy)
|
||||
|
||||
if vector_disabled:
|
||||
return _bm25_only_via_sqlite(
|
||||
query,
|
||||
palace_path,
|
||||
return _vector_disabled_search(
|
||||
query=query,
|
||||
palace_path=palace_path,
|
||||
wing=wing,
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
collection_name=collection_name,
|
||||
)
|
||||
|
||||
try:
|
||||
drawers_col = get_collection(palace_path, collection_name=collection_name, create=False)
|
||||
except Exception as e:
|
||||
logger.error("No palace found at %s: %s", palace_path, e)
|
||||
return {
|
||||
"error": "No palace found",
|
||||
"hint": "Run: mempalace init <dir> && mempalace mine <dir>",
|
||||
}
|
||||
drawers_col, open_error = _open_search_collection(palace_path, collection_name)
|
||||
if open_error:
|
||||
return open_error
|
||||
|
||||
metric = _metric_for_collection(drawers_col)
|
||||
where = build_where_filter(wing, room)
|
||||
|
||||
# Hybrid retrieval: always query drawers directly (the floor), then use
|
||||
@@ -834,7 +1082,9 @@ def search_memories(
|
||||
}
|
||||
if where:
|
||||
dkwargs["where"] = where
|
||||
drawer_results = drawers_col.query(**dkwargs)
|
||||
drawer_results = _query_drawers_with_filter_fallback(
|
||||
drawers_col, dkwargs, query, n_results, wing, room
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": f"Search error: {e}"}
|
||||
|
||||
@@ -907,7 +1157,7 @@ def search_memories(
|
||||
"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),
|
||||
"similarity": round(_distance_to_similarity(effective_dist, metric), 3),
|
||||
"distance": round(dist, 4),
|
||||
"effective_distance": round(effective_dist, 4),
|
||||
"closet_boost": round(boost, 3),
|
||||
@@ -919,6 +1169,7 @@ def search_memories(
|
||||
"_sort_key": effective_dist,
|
||||
"_source_file_full": source,
|
||||
"_chunk_index": meta.get("chunk_index"),
|
||||
"_parent_drawer_id": meta.get("parent_drawer_id"),
|
||||
}
|
||||
if closet_preview:
|
||||
entry["closet_preview"] = closet_preview
|
||||
@@ -939,9 +1190,11 @@ def search_memories(
|
||||
full_source = h.get("_source_file_full") or ""
|
||||
if not full_source:
|
||||
continue
|
||||
# Narrow by ``parent_drawer_id`` when present so unrelated
|
||||
# chunked drawers sharing ``source_file`` do not stitch (#1580).
|
||||
try:
|
||||
source_drawers = drawers_col.get(
|
||||
where={"source_file": full_source},
|
||||
where=_scoped_source_filter(full_source, h.get("_parent_drawer_id")),
|
||||
include=["documents", "metadatas"],
|
||||
)
|
||||
except Exception:
|
||||
@@ -985,31 +1238,24 @@ def search_memories(
|
||||
|
||||
# Candidate strategy hook: optionally widen the rerank pool's *source*
|
||||
# before ranking. Default ("vector") is a no-op; "union" merges top-K
|
||||
# BM25 candidates from sqlite. See `_apply_candidate_strategy`.
|
||||
# backend lexical candidates. See `_apply_candidate_strategy`.
|
||||
# ``max_distance`` is forwarded so union mode can refuse to inject
|
||||
# BM25-only (distance=None) candidates that would silently bypass the
|
||||
# caller's strict distance threshold.
|
||||
_apply_candidate_strategy(
|
||||
candidate_strategy,
|
||||
hits,
|
||||
query,
|
||||
palace_path,
|
||||
wing,
|
||||
room,
|
||||
n_results,
|
||||
# The helper also runs the final BM25 hybrid re-rank and strips internal
|
||||
# dedup fields before returning.
|
||||
hits, strategy_error = _finalize_candidate_hits(
|
||||
candidate_strategy=candidate_strategy,
|
||||
hits=hits,
|
||||
drawers_col=drawers_col,
|
||||
query=query,
|
||||
wing=wing,
|
||||
room=room,
|
||||
n_results=n_results,
|
||||
max_distance=max_distance,
|
||||
)
|
||||
|
||||
# BM25 hybrid re-rank within the final candidate set, then trim back
|
||||
# to the requested size. Without the trim, ``candidate_strategy="union"``
|
||||
# would return up to 4× ``n_results`` (vector hits + BM25 union pool),
|
||||
# breaking the existing ``search_memories`` size contract that the MCP
|
||||
# ``limit`` parameter is built on.
|
||||
hits = _hybrid_rank(hits, query)[:n_results]
|
||||
for h in hits:
|
||||
h.pop("_sort_key", None)
|
||||
h.pop("_source_file_full", None)
|
||||
h.pop("_chunk_index", None)
|
||||
if strategy_error:
|
||||
return strategy_error
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the MemPalace package version."""
|
||||
|
||||
__version__ = "3.3.6"
|
||||
__version__ = "3.4.1"
|
||||
|
||||
+20
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mempalace"
|
||||
version = "3.3.6"
|
||||
version = "3.4.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"
|
||||
@@ -60,6 +60,9 @@ mempalace-mcp = "mempalace.mcp_server:main"
|
||||
|
||||
[project.entry-points."mempalace.backends"]
|
||||
chroma = "mempalace.backends.chroma:ChromaBackend"
|
||||
pgvector = "mempalace.backends.pgvector:PgVectorBackend"
|
||||
qdrant = "mempalace.backends.qdrant:QdrantBackend"
|
||||
sqlite_exact = "mempalace.backends.sqlite_exact:SQLiteExactBackend"
|
||||
|
||||
# RFC 002 source-adapter entry-point group. Core publishes no first-party
|
||||
# adapters under this group yet; ``miner.py`` and ``convo_miner.py`` migrate
|
||||
@@ -71,7 +74,7 @@ chroma = "mempalace.backends.chroma:ChromaBackend"
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
"ruff==0.15.14",
|
||||
"ruff==0.15.15",
|
||||
"psutil>=5.9",
|
||||
# Property-based testing — generates hundreds of random inputs per
|
||||
# test to find counterexamples the hand-written positive tests miss.
|
||||
@@ -92,6 +95,11 @@ dev = [
|
||||
"mypy>=1.0",
|
||||
]
|
||||
spellcheck = ["autocorrect>=2.0"]
|
||||
# Opt-in Postgres + pgvector backend. Only the psycopg driver is needed on the
|
||||
# client; the server must have the `vector` extension available. Selected via
|
||||
# MEMPALACE_BACKEND=pgvector / --backend pgvector; never required for the
|
||||
# default (Chroma) install.
|
||||
pgvector = ["psycopg[binary]>=3.1"]
|
||||
# Hardware acceleration for the ONNX embedding model. Install exactly one:
|
||||
# pip install mempalace[gpu] — NVIDIA CUDA
|
||||
# pip install mempalace[dml] — DirectML (Windows AMD/Intel/NVIDIA)
|
||||
@@ -123,7 +131,7 @@ extract = [
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
"ruff==0.15.14",
|
||||
"ruff==0.15.15",
|
||||
"psutil>=5.9",
|
||||
"hypothesis>=6.0",
|
||||
"pre-commit>=3.0",
|
||||
@@ -161,6 +169,15 @@ markers = [
|
||||
"slow: tests that take more than 30 seconds",
|
||||
"stress: destructive scale tests (100K+ drawers)",
|
||||
]
|
||||
filterwarnings = [
|
||||
# Many tests build raw-chromadb palaces directly (no recorded embedder
|
||||
# identity), which correctly emits this on open. The behavior itself is
|
||||
# asserted in tests/test_embedder_identity.py; ignore the fixture noise.
|
||||
# Matched by message (not by class path) so pytest does not import the
|
||||
# mempalace package at config time — importing it before coverage starts
|
||||
# would drop module-level lines from the report.
|
||||
"ignore:palace collection.*has no recorded embedder identity",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["mempalace"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
description: When the user asks about past work, prior decisions, people, projects, or events that may be filed in MemPalace, call mempalace_search (or mempalace_kg_query for relational or time-bound facts) before answering from model memory. Return stored content verbatim; never guess when the palace might know.
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# MemPalace recall
|
||||
|
||||
Before answering anything that may already be in the user's memory
|
||||
palace — past work, prior decisions, a person, a project, or "what did
|
||||
we do / decide / discuss last time?" — search the palace first:
|
||||
|
||||
1. Call `mempalace_search` with a short keyword query. Use
|
||||
`mempalace_kg_query` for relational or time-bound facts.
|
||||
2. Quote the drawer's **verbatim** text. Never summarize or paraphrase
|
||||
stored content.
|
||||
3. If results are empty, say so — do not invent an answer. If the MCP
|
||||
server is unavailable, surface the error; do not fall back to guessing.
|
||||
|
||||
Skip recall for pure greenfield work with no memory relevance (renaming
|
||||
a variable, fixing a typo). Recall is question-driven, not reflexive.
|
||||
|
||||
Full protocol: `integrations/shared/recall-protocol.md`. Deeper guidance:
|
||||
the `mempalace-recall` skill.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: mempalace-recall
|
||||
description: "Recall protocol for MemPalace — search the palace before answering about past work, people, projects, or prior decisions. Apply when the user asks what was decided, what happened before, who someone is, what was discussed last time, or anything that may already be filed in their memory palace; or when mempalace-recall is invoked. Complements the mempalace setup skill and requires the mempalace-mcp server."
|
||||
---
|
||||
|
||||
# MemPalace Recall
|
||||
|
||||
Search-before-answer protocol for MemPalace. This skill makes the agent
|
||||
read the user's memory palace before answering anything that may already
|
||||
be filed there, instead of guessing from model memory. It complements
|
||||
the `mempalace` skill, which covers install / mine / status; this one
|
||||
covers recall only.
|
||||
|
||||
## Step 0 — Verify MemPalace is available
|
||||
|
||||
Before relying on recall, confirm MemPalace is installed and reachable:
|
||||
|
||||
- Official release page: <https://github.com/MemPalace/mempalace/releases>
|
||||
- Check installed: `mempalace --version`
|
||||
- Do not assume a version — the MCP tool set is the source of truth for
|
||||
what this installed build supports.
|
||||
|
||||
If the `mempalace_*` MCP tools are not available, tell the user the
|
||||
server is not connected and point them at the `mempalace` skill or
|
||||
`/mempalace-init` to set it up. Do not silently fall back to answering
|
||||
from model memory.
|
||||
|
||||
## Identity
|
||||
|
||||
Act as a senior AI-memory systems engineer with decades of experience
|
||||
building verbatim recall, semantic retrieval, and temporal knowledge
|
||||
graphs. Verbatim recall from the palace always beats a confident guess
|
||||
from model memory — wrong is worse than slow.
|
||||
|
||||
## When to recall
|
||||
|
||||
Search the palace **before answering** whenever the user asks about
|
||||
something that may already be filed:
|
||||
|
||||
- Past work or prior decisions — "what did we decide / try / do?"
|
||||
- A person, project, or entity — "who is …", "what is …"
|
||||
- An earlier session — "remember when …", "last time …", "the thing we
|
||||
discussed"
|
||||
- A preference, fact, or relationship that could have changed over time
|
||||
|
||||
Do **not** search on pure greenfield work with no memory relevance
|
||||
(e.g. "rename this variable", "fix this typo"). Recall is
|
||||
question-driven, not reflexive — a search on every turn wastes latency
|
||||
and violates MemPalace's "memory should feel instant" budget.
|
||||
|
||||
## Protocol
|
||||
|
||||
1. On wake-up, if a session-start hook injected `additional_context`,
|
||||
honour its wing scoping.
|
||||
2. Before responding about people / projects / past events / prior
|
||||
decisions: call `mempalace_search` first. Use `mempalace_kg_query`
|
||||
for relational or time-bound facts.
|
||||
3. If unsure about a fact: say "let me check the palace" and query.
|
||||
4. Return the drawer's **verbatim** text. Never summarize or paraphrase
|
||||
stored content — quoting the exact words is the point of the system.
|
||||
5. After a substantive session, record continuity with
|
||||
`mempalace_diary_write` (skip if a background hook already saved).
|
||||
6. When a fact changes: `mempalace_kg_invalidate` the old fact, then
|
||||
`mempalace_kg_add` the new one.
|
||||
|
||||
The full canonical protocol — shared verbatim with the Cursor recall
|
||||
rule and the other integrations — lives in
|
||||
[`integrations/shared/recall-protocol.md`](../../integrations/shared/recall-protocol.md).
|
||||
|
||||
## Tool selection
|
||||
|
||||
| You need | Tool |
|
||||
|---|---|
|
||||
| Find any memory by meaning | `mempalace_search` (start here) |
|
||||
| Relational / time-bound facts about an entity | `mempalace_kg_query` |
|
||||
| The chronological story of an entity | `mempalace_kg_timeline` |
|
||||
| Recent session continuity | `mempalace_diary_read` |
|
||||
| Which wings / rooms exist (scope unknown) | `mempalace_list_wings`, `mempalace_list_rooms` |
|
||||
| Record this session | `mempalace_diary_write` |
|
||||
|
||||
`mempalace_search` takes a short natural-language `query` (keywords or a
|
||||
question — not a system prompt or pasted conversation) plus optional
|
||||
`wing` / `room` filters and `limit` (default 5).
|
||||
|
||||
## Unhappy paths
|
||||
|
||||
- **Empty results.** Say the palace has nothing on this; do not invent an
|
||||
answer. Offer to widen the search (drop the `wing` filter) or to file
|
||||
the new information.
|
||||
- **MCP error / server down.** Surface the error and suggest the user
|
||||
run `mempalace status` or re-run `/mempalace-init`. Never fall back to
|
||||
guessing.
|
||||
- **Conflicting facts.** Trust the knowledge graph's time-valid answer;
|
||||
invalidate-then-add rather than overwriting silently.
|
||||
|
||||
## Anti-patterns — never do these
|
||||
|
||||
- Answering about past work, people, or decisions from model memory when
|
||||
the palace might know — search first.
|
||||
- Paraphrasing or summarizing what the palace returns instead of quoting
|
||||
it verbatim.
|
||||
- Searching on every turn, including greenfield tasks with no memory
|
||||
relevance.
|
||||
- Pasting the whole conversation or a system prompt into the `query`
|
||||
argument — keep queries short and keyword-driven.
|
||||
|
||||
## Official References
|
||||
|
||||
- MemPalace: <https://github.com/MemPalace/mempalace>
|
||||
- MemPalace releases: <https://github.com/MemPalace/mempalace/releases>
|
||||
- Cursor Skills documentation: <https://cursor.com/docs/skills>
|
||||
- Agent Skills specification: <https://agentskills.io/specification>
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: mempalace
|
||||
description: MemPalace — mine projects and conversations into a searchable memory palace. Use when the user asks about MemPalace, memory palace, mining memories, searching memories, palace setup, wings, rooms, or drawers; or when they want to recall past work that may already be filed in their palace.
|
||||
---
|
||||
|
||||
# MemPalace
|
||||
|
||||
A searchable memory palace for AI — mine projects and conversations, then search them semantically.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure `mempalace` is installed:
|
||||
|
||||
```bash
|
||||
mempalace --version
|
||||
```
|
||||
|
||||
If not installed (uv recommended):
|
||||
|
||||
```bash
|
||||
uv tool install mempalace # or: pip install mempalace
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
MemPalace provides dynamic, version-correct instructions via the CLI. To get instructions for any operation:
|
||||
|
||||
```bash
|
||||
mempalace instructions <command>
|
||||
```
|
||||
|
||||
Where `<command>` is one of: `help`, `init`, `mine`, `search`, `status`.
|
||||
|
||||
Run the appropriate instructions command, then follow the returned instructions step by step.
|
||||
|
||||
## Recalling past work
|
||||
|
||||
This skill covers setup, mining, and status. For questions about past
|
||||
work, prior decisions, or people that may already be filed in the
|
||||
palace, prefer the **`mempalace-recall`** skill — it enforces
|
||||
search-before-answer so the agent reads the palace instead of guessing.
|
||||
|
||||
## Cursor-specific notes
|
||||
|
||||
- The `mempalace-mcp` server is auto-registered by this plugin. Once installed, all 33 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, etc.) are available to the agent without any further configuration.
|
||||
- For automatic background saving every N agent turns plus session-start memory recall, also install the Cursor hooks separately by running `hooks/cursor/install.sh --scope user` from a cloned MemPalace repo. See [`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) for the full walkthrough.
|
||||
- The recommended `agent_name` when calling `mempalace_diary_write` from a Cursor session is `cursor-ide` (matches the precedent of `claude-code` and `codex`).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user