feat: add Antigravity IDE support (plugin, MCP, skill, hooks, docs, tests)

Adds first-class integration with Google's Antigravity IDE
(https://antigravity.google/) as a third sibling to the existing
Claude Code and Codex hook integrations. Strictly additive — no
existing files in main are restructured.

What ships
----------

* `.antigravity-plugin/` — verified-minimal plugin package:
  * `plugin.json` with `{"name": "mempalace"}` (no fabricated fields)
  * `mcp_config.json` registering the `mempalace-mcp` stdio server
  * `hooks.json.tmpl` templated with `__PLUGIN_DIR__` substitution
  * `skills/mempalace/SKILL.md` (real file — no symlinks)
* `hooks/antigravity/`:
  * `lib/common.sh` — shared bash 3.2.57-compatible helpers with
    sentinel-guarded camelCase JSON parser, antigravity_*-namespaced
    state files, every existing kill switch, `MEMPAL_SAVE_INTERVAL >= 1`
    floor (no /0), and fail-open emitters
  * `mempal_save_hook_antigravity.sh` — Stop event handler:
    increments per-conversation counter, defers when fullyIdle=False
    or terminationReason=error, validates transcriptPath against
    `..` traversal, spawns `mempalace mine --mode convos` in a
    detached subprocess with a per-conversation pending marker,
    ALWAYS emits `{}` (never `{"decision":"continue"}` — that would
    force an infinite agent loop)
  * `mempal_wake_hook_antigravity.sh` — PreInvocation handler gated
    to invocationNum==1 with an atomic mkdir loop guard, runs
    `mempalace wake-up` with a 500ms hard timeout, emits verbatim
    output as `{"injectSteps":[{"ephemeralMessage":"..."}]}` or
    `{}` on any failure
  * `install.sh` — idempotent installer with cmp-gated copies,
    `__PLUGIN_DIR__` substitution, relative path absolutization,
    `--dry-run`, and basename-guarded `--uninstall` (refuses to
    wipe a directory whose basename isn't `mempalace`)
  * `INVESTIGATION.md` — verbatim quotes + URLs + dates from the
    five official Antigravity doc pages, recording every surface
    shipped and every surface deliberately omitted
    (PreCompact equivalent, slash-commands, rules/, plugin
    permissions field — the latter is third-party fabrication)
  * `STDIN_SHAPE.md` — exact stdin/stdout contract per event with
    worked examples
  * `README.md` — local hook docs + troubleshooting
* `examples/antigravity/{hooks.json,mcp_config.json,README.md}` —
  standalone configs for users who don't want the full installer
* `website/guide/antigravity.md` + sidebar entry — VitePress guide
* Updates to `README.md`, `CHANGELOG.md` (Unreleased), `hooks/README.md`

Tests (56 new, all passing)
---------------------------

* `tests/test_antigravity_plugin_manifest.py` (11 tests) — schema
  contract on the in-repo `.antigravity-plugin/` directory, including
  guards against re-introducing the fabricated `permissions` field
  and against any symlink leak.
* `tests/test_antigravity_hooks_shell.py` (31 tests) — invokes the
  bash hooks via subprocess with synthetic camelCase stdin, asserts
  `{}` on every failure path, kill-switch coverage (env vars +
  config.json + palace nuke), divide-by-zero floor, transcript
  traversal rejection, namespacing, wing inference, and the hard
  refusal to ever emit `decision=continue` from the Stop hook.
* `tests/test_antigravity_hooks_install.py` (14 tests) — `--dry-run`
  side-effect-free, real install layout, executable bits preserved,
  byte-identical idempotent re-runs (md5 + filecmp), basename-match
  uninstall safety, refusal when plugin.json is missing or names a
  different plugin, relative path absolutization. Skipped on Windows.

Verification
------------

* `uv run pytest tests/ --ignore=tests/benchmarks -v` → 2314 passed,
  3 skipped (Windows), 1 unrelated warning
* `uv run ruff check .` → all checks passed
* `uv run ruff format --check .` → 139 files already formatted
* `bash -n` clean on common.sh, both hook scripts, install.sh
* Local install at `~/.gemini/config/plugins/mempalace/` verified end-
  to-end: layout correct, paths absolutized in hooks.json, both hooks
  fire with realistic camelCase JSON in <1s, wing inference picks
  `wing_mempalace` from workspacePaths[0], state files all
  `antigravity_*`-namespaced, second `install.sh` run produces
  byte-identical output (md5 snapshots match), uninstall removes
  only the mempalace plugin and leaves all 6 sibling Google plugins
  untouched.

Constraints honoured
--------------------

bash 3.2.57 (no mapfile / readarray / declare -A / `${var^^}`),
verbatim guarantee on all wake injections, hooks <500ms / startup
injection <100ms target (kill-switch path returns in <1.5s in CI),
zero new runtime dependencies, no telemetry, no external API,
strictly additive (existing Claude/Codex hooks unchanged).

Refs: hooks/antigravity/INVESTIGATION.md for the full audit.
This commit is contained in:
undeadindustries 2026-05-27 19:19:17 +10:00
parent db1fbe888b
commit bf156fb010
23 changed files with 3429 additions and 1 deletions

View File

@ -0,0 +1,44 @@
# 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 # the in-plugin skill discovered by Antigravity
└── 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.
## 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.

View File

@ -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
}
]
}
}

View File

@ -0,0 +1,7 @@
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
}
}

View File

@ -0,0 +1,3 @@
{
"name": "mempalace"
}

View File

@ -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.

View File

@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
## [Unreleased]
### Features
- **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/).
---
## [3.3.6] — 2026-05-24
### Features

View File

@ -93,7 +93,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).
---

View File

@ -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.

View File

@ -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
}
]
}
}

View File

@ -0,0 +1,7 @@
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
}
}

View File

@ -46,6 +46,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`:

View File

@ -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`

164
hooks/antigravity/README.md Normal file
View File

@ -0,0 +1,164 @@
# 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 `mempalace` is on `$PATH` (the `command -v mempalace` check
in the hook). On a GUI-launched Antigravity, the harness PATH may
differ from your shell PATH; export `MEMPAL_PYTHON=/abs/path/python`
or wrap `mempalace` in a venv-aware shim.
3. Check `~/.mempalace/hook_state/mine_pids/` for stuck PID slots if
mines never seem to start.
### 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.

View File

@ -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. |

341
hooks/antigravity/install.sh Executable file
View File

@ -0,0 +1,341 @@
#!/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, not
# the repo root.
(cd "$OLDPWD" 2>/dev/null || cd .) >/dev/null 2>&1
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/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: skill (real file, no symlinks at the discovery path) ────
copy_file "$PLUGIN_SRC/skills/mempalace/SKILL.md" \
"$INSTALL_DIR/skills/mempalace/SKILL.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

View File

@ -0,0 +1,323 @@
# 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 ─────────────────────────────────────
#
# Resolution order:
# 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 ───────────────────────────────────────────────────────────
#
# 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
try:
data = json.load(sys.stdin)
except Exception:
data = {}
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
if [ "$raw" -lt 1 ] 2>/dev/null; then
printf '15'
return 0
fi
printf '%s' "$raw"
}
# ── 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'
}

View File

@ -0,0 +1,220 @@
#!/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
# ── 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 atomically. Concurrent Stop
# fires for the same conversation are unlikely (Antigravity serializes
# turns) but if they do happen the integer-only validation rejects any
# garbled writes; one fire wins and the other reads 0 and re-counts.
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))
printf '%s' "$COUNT" > "$COUNTER_FILE"
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 (date -r); if stale (> 1 hour), reclaim.
if mtime=$(date -r "$PENDING_FILE" '+%s' 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 the mine subprocess. On POSIX, `nohup ... &` + redirection is
# sufficient; the parent (this hook script) can exit and the child
# reparents to init. Stdout and stderr both go to the antigravity hook
# log so a wedged mine surfaces in one place.
if command -v mempalace >/dev/null 2>&1; then
nohup mempalace mine "$TRANSCRIPT_DIR" \
--mode convos \
--wing "$WING" \
>> "$MEMPAL_AGY_LOG" 2>&1 < /dev/null &
MINE_PID=$!
mempal_log "stop" "$CONVERSATION_ID" "mine spawned pid=$MINE_PID wing=$WING"
# Schedule a marker-cleanup detach so the marker doesn't outlive a
# crashed mine. We can't `wait` because that would block the hook;
# instead, fire-and-forget a tiny watcher.
(
wait "$MINE_PID" 2>/dev/null
rm -f "$PENDING_FILE" 2>/dev/null
) >/dev/null 2>&1 < /dev/null &
else
mempal_log "stop" "$CONVERSATION_ID" "ERROR: mempalace CLI not on PATH; install or set MEMPAL_PYTHON"
rm -f "$PENDING_FILE" 2>/dev/null
fi
# ── 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

View File

@ -0,0 +1,161 @@
#!/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
try:
completed = subprocess.run(
['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

View File

@ -0,0 +1,324 @@
"""End-to-end tests for the Antigravity install.sh.
Covers:
* `--dry-run` is fully side-effect free.
* A real install creates the expected file tree.
* `hooks.json` is rendered with absolute paths (no `__PLUGIN_DIR__` leak).
* Re-running the installer is byte-identical (cmp gate works).
* `--uninstall` removes the dir cleanly.
* `--uninstall` refuses to wipe a directory whose basename isn't `mempalace`.
* `--uninstall` refuses if the dir is missing a `mempalace` plugin.json.
* Relative `--install-dir` is absolutized into the rendered hooks.json.
"""
from __future__ import annotations
import filecmp
import json
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
INSTALL_SH = REPO_ROOT / "hooks" / "antigravity" / "install.sh"
# Skip on Windows — install.sh is bash and uses POSIX path semantics.
pytestmark = pytest.mark.skipif(
os.name == "nt",
reason="install.sh is a bash script; Windows users use a separate code path.",
)
EXPECTED_FILES = (
"plugin.json",
"mcp_config.json",
"README.md",
"hooks.json",
"skills/mempalace/SKILL.md",
"hooks/lib/common.sh",
"hooks/mempal_save_hook_antigravity.sh",
"hooks/mempal_wake_hook_antigravity.sh",
)
def _run_install(
install_dir: Path,
*args: str,
cwd: Path | None = None,
timeout: float = 30.0,
) -> subprocess.CompletedProcess:
"""Invoke install.sh with the given install dir and args."""
cmd = [
"bash",
str(INSTALL_SH),
"--install-dir",
str(install_dir),
*args,
]
return subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=str(cwd) if cwd else None,
timeout=timeout,
)
def _assert_install_layout(install_dir: Path) -> None:
for rel in EXPECTED_FILES:
path = install_dir / rel
assert path.is_file(), f"missing after install: {rel}"
assert not path.is_symlink(), f"unexpected symlink at: {rel}"
# ── --dry-run ──────────────────────────────────────────────────────────
def test_dry_run_is_side_effect_free(tmp_path: Path) -> None:
"""--dry-run must not create the install dir or any of its files."""
install_dir = tmp_path / "mempalace"
result = _run_install(install_dir, "--dry-run")
assert result.returncode == 0, result.stderr
assert not install_dir.exists(), (
f"dry-run created install dir {install_dir} — that is a side effect."
)
# Output should mention DRY-RUN at least once for visibility.
assert "DRY-RUN" in result.stdout, result.stdout
# ── Real install ──────────────────────────────────────────────────────
def test_real_install_creates_full_layout(tmp_path: Path) -> None:
install_dir = tmp_path / "mempalace"
result = _run_install(install_dir)
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
_assert_install_layout(install_dir)
def test_install_renders_absolute_paths_in_hooks_json(tmp_path: Path) -> None:
"""`__PLUGIN_DIR__` must be substituted into hooks.json command paths."""
install_dir = tmp_path / "mempalace"
result = _run_install(install_dir)
assert result.returncode == 0
hooks = json.loads((install_dir / "hooks.json").read_text())
# Every "command" string must be absolute and live under the
# install dir.
cmds = []
for ns_payload in hooks.values():
if not isinstance(ns_payload, dict):
continue
for entries in ns_payload.values():
for entry in entries:
cmds.append(entry["command"])
assert cmds, f"no command entries found in rendered hooks.json: {hooks}"
install_str = str(install_dir)
for cmd in cmds:
assert "__PLUGIN_DIR__" not in cmd, f"placeholder leaked into rendered hooks.json: {cmd!r}"
assert cmd.startswith("/"), f"command path is not absolute: {cmd!r}"
assert cmd.startswith(install_str + "/"), (
f"command path {cmd!r} does not live under install dir {install_str!r}"
)
def test_install_executable_bits_preserved(tmp_path: Path) -> None:
"""Both hook scripts must end up executable on the install side."""
install_dir = tmp_path / "mempalace"
result = _run_install(install_dir)
assert result.returncode == 0
for rel in (
"hooks/mempal_save_hook_antigravity.sh",
"hooks/mempal_wake_hook_antigravity.sh",
):
path = install_dir / rel
assert os.access(path, os.X_OK), f"hook script not executable: {rel}"
# ── Idempotency ───────────────────────────────────────────────────────
def test_install_is_byte_identical_on_re_run(tmp_path: Path) -> None:
"""Re-running the installer should leave every file byte-identical.
The `cmp`-gated copy and template render is what makes the
installer safe to run from CI and from `babysit`-style cron loops.
"""
install_dir = tmp_path / "mempalace"
first = _run_install(install_dir)
assert first.returncode == 0, first.stderr
# Snapshot every file's content + mtime + mode.
snap1: dict[str, tuple[bytes, float, int]] = {}
for rel in EXPECTED_FILES:
p = install_dir / rel
st = p.stat()
snap1[rel] = (p.read_bytes(), st.st_mtime, st.st_mode)
# Re-run.
second = _run_install(install_dir)
assert second.returncode == 0, second.stderr
# Every file's contents must be byte-identical.
for rel in EXPECTED_FILES:
p = install_dir / rel
body, _, mode = snap1[rel]
assert p.read_bytes() == body, f"{rel} differs after re-install"
# Mode must be preserved (we don't enforce mtime since the
# cmp gate explicitly avoids re-writing).
assert p.stat().st_mode == mode, f"{rel} mode changed after re-install"
# Use filecmp.dircmp as a belt-and-suspenders check.
cmp = filecmp.dircmp(install_dir, install_dir)
assert not cmp.diff_files
def test_install_logs_no_writes_on_idempotent_re_run(tmp_path: Path) -> None:
"""The second run must not log "wrote: ..." for any file."""
install_dir = tmp_path / "mempalace"
first = _run_install(install_dir)
assert first.returncode == 0
# First run writes everything.
assert "wrote:" in first.stdout
second = _run_install(install_dir)
assert second.returncode == 0
assert "wrote:" not in second.stdout, (
f"second install should be a no-op but wrote files:\n{second.stdout}"
)
# ── Uninstall ─────────────────────────────────────────────────────────
def test_uninstall_removes_mempalace_install(tmp_path: Path) -> None:
install_dir = tmp_path / "mempalace"
install = _run_install(install_dir)
assert install.returncode == 0
uninstall = _run_install(install_dir, "--uninstall")
assert uninstall.returncode == 0, uninstall.stderr
assert not install_dir.exists()
def test_uninstall_refuses_basename_mismatch(tmp_path: Path) -> None:
"""Refuses to remove a directory whose basename isn't 'mempalace'.
Honours the basename-match safety guard caught in the cursor PR
review prevents an accidental wipe of a sibling like
'mempalace-foo' or, in the worst case, the user's home directory.
"""
bad_dir = tmp_path / "totally-not-mempalace"
bad_dir.mkdir()
# Even though the dir has a plugin.json with name=mempalace, the
# basename mismatch must still refuse.
(bad_dir / "plugin.json").write_text(json.dumps({"name": "mempalace"}), encoding="utf-8")
sentinel = bad_dir / "do-not-delete.txt"
sentinel.write_text("preserve me", encoding="utf-8")
result = _run_install(bad_dir, "--uninstall")
assert result.returncode != 0, (
f"uninstall should have refused but exited 0:\n{result.stdout}\n{result.stderr}"
)
assert bad_dir.is_dir(), f"bad uninstall removed {bad_dir}"
assert sentinel.is_file(), "uninstall removed unrelated files inside bad dir"
def test_uninstall_refuses_when_plugin_json_missing(tmp_path: Path) -> None:
"""Refuses when the dir is missing plugin.json (not actually our plugin)."""
install_dir = tmp_path / "mempalace"
install_dir.mkdir()
sentinel = install_dir / "stranger.txt"
sentinel.write_text("preserve me", encoding="utf-8")
result = _run_install(install_dir, "--uninstall")
assert result.returncode != 0
assert install_dir.is_dir()
assert sentinel.is_file()
def test_uninstall_refuses_when_plugin_json_wrong_name(tmp_path: Path) -> None:
"""Refuses when plugin.json names a different plugin."""
install_dir = tmp_path / "mempalace"
install_dir.mkdir()
(install_dir / "plugin.json").write_text(
json.dumps({"name": "some-other-plugin"}), encoding="utf-8"
)
sentinel = install_dir / "preserved.txt"
sentinel.write_text("safe", encoding="utf-8")
result = _run_install(install_dir, "--uninstall")
assert result.returncode != 0
assert install_dir.is_dir()
assert sentinel.is_file()
def test_uninstall_no_op_when_target_missing(tmp_path: Path) -> None:
"""Uninstalling a non-existent dir is a graceful no-op."""
install_dir = tmp_path / "mempalace"
result = _run_install(install_dir, "--uninstall")
assert result.returncode == 0, result.stderr
# ── Relative path absolutization ──────────────────────────────────────
def test_relative_install_dir_is_absolutized(tmp_path: Path) -> None:
"""A relative --install-dir must be absolutized in the rendered hooks.json.
Catches the cursor PR review issue where a relative path baked
in verbatim left Antigravity unable to resolve hook commands.
"""
work = tmp_path / "work"
work.mkdir()
# Relative path resolved against $PWD at invocation time.
rel = "build/agy-out/mempalace"
result = _run_install(Path(rel), cwd=work)
assert result.returncode == 0, result.stderr
abs_install = work / rel
assert abs_install.is_dir(), f"installer did not create {abs_install} from relative path {rel}"
hooks = json.loads((abs_install / "hooks.json").read_text())
cmds = []
for ns_payload in hooks.values():
if not isinstance(ns_payload, dict):
continue
for entries in ns_payload.values():
for entry in entries:
cmds.append(entry["command"])
for cmd in cmds:
assert cmd.startswith("/"), f"relative install dir leaked into rendered hooks.json: {cmd!r}"
assert "build/agy-out/mempalace" in cmd, (
f"command path lost the relative-segment context: {cmd!r}"
)
# ── Misc ──────────────────────────────────────────────────────────────
def test_install_help_does_not_write(tmp_path: Path) -> None:
"""`--help` should print usage and exit 0 without touching the dir."""
install_dir = tmp_path / "mempalace"
result = subprocess.run(
["bash", str(INSTALL_SH), "--help"],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0
assert "Usage:" in result.stdout or "Usage:" in result.stderr
assert not install_dir.exists()
def test_install_unknown_arg_exits_non_zero(tmp_path: Path) -> None:
"""Unknown args must fail loudly rather than silently ignoring."""
install_dir = tmp_path / "mempalace"
result = subprocess.run(
[
"bash",
str(INSTALL_SH),
"--install-dir",
str(install_dir),
"--this-flag-does-not-exist",
],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode != 0
assert not install_dir.exists()

View File

@ -0,0 +1,607 @@
"""End-to-end shell tests for the Antigravity hook scripts.
Invokes the bash scripts directly via subprocess with synthetic stdin
JSON and asserts on their stdout / exit code / state-dir side effects.
The two scripts under test are:
* `hooks/antigravity/mempal_save_hook_antigravity.sh` Stop event
* `hooks/antigravity/mempal_wake_hook_antigravity.sh` PreInvocation event
Test isolation:
* Each test runs in its own temp dir.
* `MEMPAL_STATE_DIR` is overridden to point at the temp dir, so no
test ever touches the real `~/.mempalace/hook_state/`.
* `HOME` is overridden to a temp dir as well so the kill-switch
palace-existence check sees a hermetic state.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
HOOKS_DIR = REPO_ROOT / "hooks" / "antigravity"
SAVE_HOOK = HOOKS_DIR / "mempal_save_hook_antigravity.sh"
WAKE_HOOK = HOOKS_DIR / "mempal_wake_hook_antigravity.sh"
COMMON_LIB = HOOKS_DIR / "lib" / "common.sh"
# Skip the entire module on Windows — bash 3.2+ is required.
pytestmark = pytest.mark.skipif(
os.name == "nt",
reason="Antigravity shell hooks require bash; Windows uses a separate code path.",
)
def _run_hook(
script: Path,
stdin_json: dict | str,
state_dir: Path,
home: Path,
extra_env: dict[str, str] | None = None,
timeout: float = 10.0,
) -> subprocess.CompletedProcess:
"""Run a hook script with isolated env and synthetic stdin."""
if isinstance(stdin_json, dict):
stdin = json.dumps(stdin_json)
else:
stdin = stdin_json
env = os.environ.copy()
# Hermetic env: HOME and state dir point at the test temp.
home.mkdir(parents=True, exist_ok=True)
state_dir.mkdir(parents=True, exist_ok=True)
env["HOME"] = str(home)
env["MEMPAL_STATE_DIR"] = str(state_dir)
# Drop any leftover kill-switch envs from the user's environment so
# the test exercises the gate it intends to.
for k in ("MEMPAL_DISABLE_HOOK", "MEMPALACE_HOOKS_AUTO_SAVE", "MEMPAL_SAVE_INTERVAL"):
env.pop(k, None)
if extra_env:
env.update(extra_env)
return subprocess.run(
["bash", str(script)],
input=stdin,
capture_output=True,
text=True,
env=env,
timeout=timeout,
)
def _ensure_palace(home: Path) -> None:
"""Create $HOME/.mempalace/ so the palace-nuke kill switch passes."""
(home / ".mempalace").mkdir(parents=True, exist_ok=True)
def _stop_payload(**overrides) -> dict:
base = {
"executionNum": 1,
"terminationReason": "model_stop",
"error": "",
"fullyIdle": True,
"conversationId": "test-conv-001",
"workspacePaths": ["/tmp/test-workspace"],
"transcriptPath": "/tmp/test-transcript.jsonl",
"artifactDirectoryPath": "/tmp/test-artifacts/",
}
base.update(overrides)
return base
def _wake_payload(**overrides) -> dict:
base = {
"invocationNum": 1,
"initialNumSteps": 0,
"conversationId": "test-conv-001",
"workspacePaths": ["/tmp/test-workspace"],
"transcriptPath": "/tmp/test-transcript.jsonl",
"artifactDirectoryPath": "/tmp/test-artifacts/",
}
base.update(overrides)
return base
# ── Syntax (bash -n) ──────────────────────────────────────────────────
@pytest.mark.parametrize("script", [SAVE_HOOK, WAKE_HOOK, COMMON_LIB], ids=lambda p: p.name)
def test_bash_n_clean(script: Path) -> None:
"""All shell files parse cleanly under bash 3.2+."""
result = subprocess.run(
["bash", "-n", str(script)],
capture_output=True,
text=True,
timeout=10,
)
assert result.returncode == 0, f"bash -n {script.name} failed:\n{result.stderr}"
# ── Save hook ─────────────────────────────────────────────────────────
def test_save_hook_emits_empty_object_on_kill_switch_env(tmp_path: Path) -> None:
"""MEMPAL_DISABLE_HOOK=1 should silently emit `{}` and exit 0."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_DISABLE_HOOK": "1"},
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "{}", result.stdout
def test_save_hook_emits_empty_object_on_auto_save_false(tmp_path: Path) -> None:
"""MEMPALACE_HOOKS_AUTO_SAVE=false short-circuits."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPALACE_HOOKS_AUTO_SAVE": "false"},
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "{}"
def test_save_hook_emits_empty_object_when_palace_dir_missing(tmp_path: Path) -> None:
"""Removing $HOME/.mempalace acts as the strongest kill switch."""
state = tmp_path / "state"
home = tmp_path / "home"
home.mkdir()
# Deliberately do NOT create ~/.mempalace
result = _run_hook(SAVE_HOOK, _stop_payload(), state_dir=state, home=home)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_emits_empty_object_when_config_disables(tmp_path: Path) -> None:
"""~/.mempalace/config.json `hooks.auto_save: false` short-circuits."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
(home / ".mempalace" / "config.json").write_text(
json.dumps({"hooks": {"auto_save": False}}),
encoding="utf-8",
)
result = _run_hook(SAVE_HOOK, _stop_payload(), state_dir=state, home=home)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_emits_empty_object_when_fully_idle_false(tmp_path: Path) -> None:
"""fullyIdle=False defers the save; nothing should write to state."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(fullyIdle=False),
state_dir=state,
home=home,
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
# The counter file must NOT exist — we deferred before incrementing.
counter = state / "antigravity_save_count_test-conv-001"
assert not counter.exists(), f"counter advanced despite fullyIdle=false: {counter}"
def test_save_hook_emits_empty_object_on_error_termination(tmp_path: Path) -> None:
"""terminationReason=error skips the save."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(terminationReason="error", error="model crashed"),
state_dir=state,
home=home,
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_emits_empty_object_on_malformed_stdin(tmp_path: Path) -> None:
"""Malformed JSON must not crash the hook — fail-open behaviour."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
"{not even close to json{",
state_dir=state,
home=home,
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_emits_empty_object_on_empty_stdin(tmp_path: Path) -> None:
"""Empty stdin must not crash the hook."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(SAVE_HOOK, "", state_dir=state, home=home)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_never_emits_decision_continue(tmp_path: Path) -> None:
"""The save hook must NEVER emit `{"decision":"continue"}`.
That output would force Antigravity into an infinite agent
re-execution loop. Hard rule, separately tested.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(SAVE_HOOK, _stop_payload(), state_dir=state, home=home)
assert result.returncode == 0
# Parse the output so we don't false-match on substring of
# "Continue thread of work" or similar prose.
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
pytest.fail(f"save hook emitted non-JSON: {result.stdout!r}")
assert payload.get("decision") != "continue", (
f"save hook emitted decision=continue, which would force an infinite "
f"agent loop. payload={payload!r}"
)
def test_save_hook_counter_increments_per_fire(tmp_path: Path) -> None:
"""Counter advances on each Stop fire."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
counter_path = state / "antigravity_save_count_test-conv-001"
for expected in (1, 2, 3):
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "999"}, # high interval -> never trigger save
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
assert counter_path.is_file()
assert counter_path.read_text().strip() == str(expected)
def test_save_hook_floors_zero_save_interval_to_avoid_div_by_zero(tmp_path: Path) -> None:
"""MEMPAL_SAVE_INTERVAL=0 must be floored, never cause `count % 0`."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "0"},
)
# Must NOT crash with bash arithmetic divide-by-zero.
assert result.returncode == 0, (
f"save hook crashed on MEMPAL_SAVE_INTERVAL=0:\n"
f"stdout={result.stdout!r}\nstderr={result.stderr!r}"
)
assert result.stdout.strip() == "{}"
def test_save_hook_floors_negative_save_interval(tmp_path: Path) -> None:
"""Negative MEMPAL_SAVE_INTERVAL falls back to default (no crash)."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "-5"},
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "{}"
def test_save_hook_rejects_traversal_in_transcript_path(tmp_path: Path) -> None:
"""A `..` segment in transcriptPath must be rejected."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
# Set interval to 1 so the modulo gate would normally fire on the
# first Stop, then prove the path validator stops the spawn.
result = _run_hook(
SAVE_HOOK,
_stop_payload(transcriptPath="/legit/../etc/passwd"),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
log = state / "antigravity_hook.log"
assert log.is_file()
log_body = log.read_text()
assert "invalid transcriptPath rejected" in log_body or "does not exist" in log_body
def test_save_hook_rejects_non_jsonl_transcript_path(tmp_path: Path) -> None:
"""A transcriptPath ending in something other than .json[l] is rejected."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(transcriptPath="/tmp/transcript.txt"),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
def test_save_hook_state_files_are_namespaced_antigravity(tmp_path: Path) -> None:
"""Every state file the save hook touches starts with `antigravity_`.
The shared state directory is also home to Claude Code, Codex, and
(in the future) Cursor hook state. Namespacing prevents collisions.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "999"},
)
assert result.returncode == 0
leaks = [p.name for p in state.iterdir() if not p.name.startswith("antigravity_")]
assert not leaks, f"save hook created non-antigravity-namespaced state files: {leaks}"
def test_save_hook_pending_marker_blocks_concurrent_save(tmp_path: Path) -> None:
"""A fresh pending marker should cause the next save to skip."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
pending = state / "antigravity_pending_test-conv-001"
state.mkdir(parents=True, exist_ok=True)
pending.touch()
# Force the modulo gate to fire by setting interval=1.
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
log_body = (state / "antigravity_hook.log").read_text(errors="replace")
assert "pending save still in flight" in log_body
# ── Wake hook ─────────────────────────────────────────────────────────
def test_wake_hook_emits_empty_object_on_kill_switch(tmp_path: Path) -> None:
"""MEMPAL_DISABLE_HOOK=1 silences the wake hook."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
WAKE_HOOK,
_wake_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_DISABLE_HOOK": "1"},
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
@pytest.mark.parametrize("invocation", [0, 2, 5, 100])
def test_wake_hook_emits_empty_when_invocation_num_not_one(tmp_path: Path, invocation: int) -> None:
"""Only invocationNum == 1 triggers injection."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
WAKE_HOOK,
_wake_payload(invocationNum=invocation),
state_dir=state,
home=home,
)
assert result.returncode == 0
assert result.stdout.strip() == "{}", (
f"wake hook injected at invocationNum={invocation}: {result.stdout!r}"
)
def test_wake_hook_loop_guard_prevents_repeat_injection(tmp_path: Path) -> None:
"""A second fire for the same conversationId must skip via the mkdir guard."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
# Pre-create the woke marker dir.
woke = state / "antigravity_woke_test-conv-001"
state.mkdir(parents=True, exist_ok=True)
woke.mkdir()
result = _run_hook(
WAKE_HOOK,
_wake_payload(),
state_dir=state,
home=home,
)
assert result.returncode == 0
assert result.stdout.strip() == "{}"
log_body = (state / "antigravity_hook.log").read_text(errors="replace")
assert "already woke this conversation" in log_body
def test_wake_hook_never_emits_decision_field(tmp_path: Path) -> None:
"""The wake hook must never emit a `decision` key (that field is Stop-only)."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(
WAKE_HOOK,
_wake_payload(),
state_dir=state,
home=home,
)
assert result.returncode == 0
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
pytest.fail(f"wake hook emitted non-JSON: {result.stdout!r}")
assert "decision" not in payload, f"wake hook emitted a decision field: {payload!r}"
def test_wake_hook_emits_empty_when_mempalace_missing(tmp_path: Path) -> None:
"""When `mempalace` is not on PATH, the wake hook degrades to `{}`.
Antigravity's hook framework should never see a stack trace from
a missing CLI emit `{}` and let the conversation start without
injection.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
# Strip PATH down to just the bash + python essentials, dropping
# any directory that might have a `mempalace` binary.
minimal_path = "/usr/bin:/bin"
result = _run_hook(
WAKE_HOOK,
_wake_payload(),
state_dir=state,
home=home,
extra_env={"PATH": minimal_path},
)
assert result.returncode == 0, (
f"wake hook crashed when mempalace is missing:\n"
f"stdout={result.stdout!r}\nstderr={result.stderr!r}"
)
assert result.stdout.strip() == "{}"
def test_wake_hook_state_files_are_namespaced_antigravity(tmp_path: Path) -> None:
"""Wake hook state files are also namespaced."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
result = _run_hook(WAKE_HOOK, _wake_payload(), state_dir=state, home=home)
assert result.returncode == 0
leaks = [p.name for p in state.iterdir() if not p.name.startswith("antigravity_")]
assert not leaks, leaks
# ── Wing inference ────────────────────────────────────────────────────
def test_wing_inference_picks_first_workspace_path(tmp_path: Path) -> None:
"""Wing is derived from workspacePaths[0]'s leaf directory.
Antigravity sends an array; the first element is canonical.
"""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
# Set interval=1 and a real existing transcript so the save path
# logs the inferred wing.
transcript = tmp_path / "transcript.jsonl"
transcript.write_text("{}\n", encoding="utf-8")
workspace = tmp_path / "myproj-with-dashes"
workspace.mkdir()
result = _run_hook(
SAVE_HOOK,
_stop_payload(
transcriptPath=str(transcript),
workspacePaths=[str(workspace), "/some/other/workspace"],
),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert result.returncode == 0
log_body = (state / "antigravity_hook.log").read_text(errors="replace")
# Hyphens become underscores; lowercase.
assert "wing=wing_myproj_with_dashes" in log_body, log_body
def test_wing_inference_defaults_to_sessions_when_workspace_empty(tmp_path: Path) -> None:
"""An empty workspacePaths array yields wing_sessions."""
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
transcript = tmp_path / "transcript.jsonl"
transcript.write_text("{}\n", encoding="utf-8")
result = _run_hook(
SAVE_HOOK,
_stop_payload(
transcriptPath=str(transcript),
workspacePaths=[],
),
state_dir=state,
home=home,
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
)
assert result.returncode == 0
log_body = (state / "antigravity_hook.log").read_text(errors="replace")
assert "wing=wing_sessions" in log_body
# ── Performance budget (soft) ─────────────────────────────────────────
@pytest.mark.skipif(sys.platform == "win32", reason="not relevant on Windows code path")
def test_save_hook_returns_quickly_under_kill_switch(tmp_path: Path) -> None:
"""Under the kill switch the hook should return well under 1s.
The integration brief budgets hooks at <500ms. We allow a generous
1500ms here because CI machines can be slow on cold-cache subprocess
spawn. The point of the test is to fail loudly if a future edit
introduces a synchronous mempalace import or DB connection.
"""
import time
state = tmp_path / "state"
home = tmp_path / "home"
_ensure_palace(home)
start = time.monotonic()
result = _run_hook(
SAVE_HOOK,
_stop_payload(),
state_dir=state,
home=home,
extra_env={"MEMPAL_DISABLE_HOOK": "1"},
)
elapsed = time.monotonic() - start
assert result.returncode == 0
assert result.stdout.strip() == "{}"
assert elapsed < 1.5, (
f"save hook under kill switch took {elapsed:.3f}s; expected < 1.5s. "
"A regression here usually means a synchronous import / DB connection "
"is happening before the kill-switch short-circuit."
)

View File

@ -0,0 +1,228 @@
"""Schema tests for the .antigravity-plugin/ directory.
Covers:
* `plugin.json` matches the verified-minimal Antigravity schema
(`{"name": "..."}`, no fabricated fields).
* `mcp_config.json` registers `mempalace-mcp` under the `mcpServers`
key with the verified shape from
https://antigravity.google/docs/mcp.
* `hooks.json.tmpl` is valid JSON, references both hook scripts via
the `__PLUGIN_DIR__` placeholder, and pins per-event timeouts
inside the safety bounds.
* `skills/mempalace/SKILL.md` exists as a real file (no symlinks) and
carries the required YAML frontmatter (`description`).
These are contract tests they fail as soon as anyone changes the
in-repo shape in a way that drifts from Antigravity's documented
schema. See [hooks/antigravity/INVESTIGATION.md](../hooks/antigravity/INVESTIGATION.md)
for the source-of-truth audit driving the assertions.
"""
import json
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
PLUGIN_DIR = REPO_ROOT / ".antigravity-plugin"
PLUGIN_JSON = PLUGIN_DIR / "plugin.json"
MCP_CONFIG = PLUGIN_DIR / "mcp_config.json"
HOOKS_TMPL = PLUGIN_DIR / "hooks.json.tmpl"
SKILL_MD = PLUGIN_DIR / "skills" / "mempalace" / "SKILL.md"
PLUGIN_README = PLUGIN_DIR / "README.md"
EXPECTED_HOOKS = {
"Stop": {
"script_basename": "mempal_save_hook_antigravity.sh",
"timeout_floor": 10,
"timeout_ceiling": 60,
},
"PreInvocation": {
"script_basename": "mempal_wake_hook_antigravity.sh",
"timeout_floor": 1,
"timeout_ceiling": 10,
},
}
def test_plugin_dir_exists() -> None:
"""The in-repo plugin directory exists and is laid out as expected."""
assert PLUGIN_DIR.is_dir(), f"missing: {PLUGIN_DIR}"
for required in (PLUGIN_JSON, MCP_CONFIG, HOOKS_TMPL, SKILL_MD, PLUGIN_README):
assert required.is_file(), f"missing: {required}"
def test_plugin_json_minimal_schema() -> None:
"""plugin.json must be `{"name": "mempalace"}` exactly — no fabricated fields.
The third-party "antigravity-plugins" community skill at
~/.gemini/skills/antigravity-plugins/SKILL.md documents a
`permissions` field that does not exist in any real
Google-shipped plugin. We pin to the verified minimal shape and
fail loudly if anyone re-introduces the fabrication.
"""
data = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
assert isinstance(data, dict), "plugin.json must be a JSON object"
assert data == {"name": "mempalace"}, (
f"plugin.json must equal {{'name': 'mempalace'}} (verified shape); "
f"got {data!r}. The `permissions` field documented in the third-party "
"antigravity-plugins community skill is fabricated; do not add it."
)
def test_mcp_config_registers_mempalace_mcp() -> None:
"""mcp_config.json must register the mempalace stdio server."""
data = json.loads(MCP_CONFIG.read_text(encoding="utf-8"))
assert isinstance(data, dict)
assert "mcpServers" in data, "missing top-level mcpServers key"
servers = data["mcpServers"]
assert isinstance(servers, dict)
assert "mempalace" in servers, "mcpServers.mempalace not registered"
entry = servers["mempalace"]
assert isinstance(entry, dict)
assert entry.get("command") == "mempalace-mcp", (
f"mcpServers.mempalace.command must be 'mempalace-mcp'; got {entry.get('command')!r}"
)
def test_hooks_template_valid_json() -> None:
"""hooks.json.tmpl must be valid JSON (the `__PLUGIN_DIR__` placeholder is JSON-safe)."""
body = HOOKS_TMPL.read_text(encoding="utf-8")
try:
data = json.loads(body)
except json.JSONDecodeError as exc:
pytest.fail(f"hooks.json.tmpl is not valid JSON: {exc}")
assert isinstance(data, dict)
def test_hooks_template_uses_plugin_dir_placeholder() -> None:
"""hooks.json.tmpl must use __PLUGIN_DIR__ — never bake an absolute path."""
body = HOOKS_TMPL.read_text(encoding="utf-8")
assert "__PLUGIN_DIR__" in body, (
"hooks.json.tmpl must use __PLUGIN_DIR__ as the install-dir placeholder. "
"Hard-coded absolute paths break the installer's idempotency promise."
)
# Any `/Users/`, `/home/`, or `~/` segment in the template body is a sign
# that an absolute path leaked in.
forbidden = ["/Users/", "/home/", "~/"]
for prefix in forbidden:
assert prefix not in body, (
f"hooks.json.tmpl must not contain a hard-coded path segment {prefix!r}; "
"use the __PLUGIN_DIR__ placeholder instead."
)
@pytest.mark.parametrize("event", sorted(EXPECTED_HOOKS))
def test_hooks_template_event_present(event: str) -> None:
"""Each expected event has exactly one entry pointing at the right script with bounded timeout."""
data = json.loads(HOOKS_TMPL.read_text(encoding="utf-8"))
bounds = EXPECTED_HOOKS[event]
# Outer keys are hook namespace names, e.g. "mempalace-save".
matching = [
(ns, payload[event])
for ns, payload in data.items()
if isinstance(payload, dict) and event in payload
]
assert len(matching) == 1, (
f"expected exactly one hook namespace declaring event {event!r}; "
f"found {len(matching)}: {[m[0] for m in matching]}"
)
_, entries = matching[0]
assert isinstance(entries, list)
assert len(entries) == 1, (
f"{event}: expected exactly one handler entry, got {len(entries)}; "
"duplicate entries would double-fire the hook"
)
handler = entries[0]
assert handler.get("type", "command") == "command", (
f"{event}: only type=command is supported by Antigravity"
)
cmd = handler.get("command", "")
assert cmd.startswith("__PLUGIN_DIR__/"), (
f"{event}: command must be rooted at __PLUGIN_DIR__/, got {cmd!r}"
)
assert cmd.endswith("/" + bounds["script_basename"]), (
f"{event}: command must end with the expected script basename "
f"{bounds['script_basename']!r}; got {cmd!r}"
)
timeout = handler.get("timeout")
is_int = isinstance(timeout, int) and not isinstance(timeout, bool)
assert is_int and bounds["timeout_floor"] <= timeout <= bounds["timeout_ceiling"], (
f"{event}: timeout must be an int in "
f"[{bounds['timeout_floor']}, {bounds['timeout_ceiling']}]s; got {timeout!r}"
)
def test_skill_is_real_file_not_symlink() -> None:
"""SKILL.md at the discovery path must be a real file.
Antigravity (like Cursor) loads skills by reading
`<plugin>/skills/<name>/SKILL.md` directly. A symlink at that path
would work locally but break under any installer that does a
plain `cp`. Honouring constraint #6 in the integration brief.
"""
assert SKILL_MD.is_file(), f"missing: {SKILL_MD}"
assert not SKILL_MD.is_symlink(), (
f"{SKILL_MD} must be a real file, not a symlink — installers that "
"cp without -L would otherwise carry the symlink into the install."
)
def test_skill_has_required_frontmatter() -> None:
"""SKILL.md must carry YAML frontmatter with a non-empty description.
Antigravity's skill loader uses the `description` field to decide
when to surface the skill. An empty / missing description would
silently disable progressive disclosure.
"""
body = SKILL_MD.read_text(encoding="utf-8")
assert body.startswith("---\n"), "SKILL.md must begin with YAML frontmatter"
end = body.find("\n---\n", 4)
assert end > 0, "SKILL.md frontmatter is missing the closing fence"
front = body[4:end]
desc_match = re.search(r"^description:\s*(.+)$", front, re.MULTILINE)
assert desc_match is not None, "SKILL.md frontmatter missing `description` key"
desc_value = desc_match.group(1).strip()
assert desc_value, "SKILL.md `description` is empty"
# Sanity: the description should be substantive enough for the
# skill loader to act on. 30 chars is a soft floor, not a tight bound.
assert len(desc_value) >= 30, (
f"SKILL.md description looks too short to be useful: {desc_value!r}"
)
def test_no_symlinks_inside_plugin_dir() -> None:
"""Nothing inside .antigravity-plugin/ may be a symlink.
This is the broader version of `test_skill_is_real_file_not_symlink`
and a guard against silent regressions if someone re-introduces
the `skills -> ../skills` symlink pattern from the original plan
without honouring `cp -RL` semantics in the installer.
"""
leaks = [p for p in PLUGIN_DIR.rglob("*") if p.is_symlink()]
assert not leaks, (
f"symlinks found inside .antigravity-plugin/: {[str(p.relative_to(PLUGIN_DIR)) for p in leaks]}; "
"the entire plugin tree must be made of real files so any installer "
"(including those that cp without -L) gets a working install."
)
def test_plugin_readme_present_and_substantive() -> None:
"""README.md inside the plugin dir must exist and be substantive.
Empty / placeholder READMEs are a frequent symptom of half-finished
refactors; a 200-byte floor catches those without being so tight
it discourages legitimate rewrites.
"""
body = PLUGIN_README.read_text(encoding="utf-8")
assert len(body) >= 200, (
f".antigravity-plugin/README.md looks too short ({len(body)} bytes); "
"expected a substantive description of layout + install."
)
# Must mention key concepts so the README can't degrade into prose
# that drops the operational links.
for needle in ("plugin.json", "mcp_config.json", "hooks.json"):
assert needle in body, f"README.md must mention {needle}"

View File

@ -57,6 +57,7 @@ export default withMermaid(
{ text: 'Claude Code Plugin', link: '/guide/claude-code' },
{ text: 'Claude Code Retention', link: '/guide/claude-code-retention' },
{ text: 'Gemini CLI', link: '/guide/gemini-cli' },
{ text: 'Antigravity Plugin', link: '/guide/antigravity' },
{ text: 'OpenClaw Skill', link: '/guide/openclaw' },
{ text: 'Local Models', link: '/guide/local-models' },
{ text: 'Auto-Save Hooks', link: '/guide/hooks' },

View File

@ -0,0 +1,226 @@
# Antigravity Plugin
MemPalace ships first-class support for Google's
[Antigravity IDE](https://antigravity.google/) as an installable
plugin. The plugin registers MemPalace's MCP server, ships the
`mempalace` skill, and wires two lifecycle hooks (Stop and
PreInvocation) for background mining and startup memory injection.
## What gets registered
| Surface | Antigravity component |
|-----------------|------------------------------------------------------------|
| MCP server | `mempalace` (stdio, runs `mempalace-mcp`) |
| Skill | `mempalace` (in-plugin `skills/mempalace/SKILL.md`) |
| Stop hook | `mempalace-save` — background-mines the conversation |
| PreInvocation | `mempalace-wake` — injects memory on the first model call |
The full audit of which Antigravity surfaces we use, why, and what we
deliberately do not ship is in [`hooks/antigravity/INVESTIGATION.md`](https://github.com/MemPalace/mempalace/blob/main/hooks/antigravity/INVESTIGATION.md).
## Prerequisites
- Python 3.9+
- [`mempalace`](https://github.com/MemPalace/mempalace) installed and
on `$PATH` (`mempalace --version` to verify)
- [Antigravity IDE](https://antigravity.google/) installed (`~/.gemini/`
exists)
## Install
From the cloned `mempalace` repo:
```bash
bash hooks/antigravity/install.sh
```
This installs to `~/.gemini/config/plugins/mempalace/`. Restart
Antigravity and the plugin loads automatically — you'll see
`mempalace` in the MCP store and the skill list.
### Dry run first
```bash
bash hooks/antigravity/install.sh --dry-run
```
### Custom install dir (workspace-scoped)
```bash
bash hooks/antigravity/install.sh \
--install-dir <workspace>/.agents/plugins/mempalace
```
The installer absolutizes any relative path before baking it into the
rendered `hooks.json`, so the resulting plugin is portable to any
working directory.
### Idempotency
Re-running the installer produces a byte-identical install —
`cmp`-gated copies skip files whose contents already match. Safe to
run from CI.
### Uninstall
```bash
bash hooks/antigravity/install.sh --uninstall
```
The uninstaller has two safety guards:
1. The basename of `--install-dir` must be exactly `mempalace`.
2. The directory must contain a `plugin.json` whose `name` is
`"mempalace"`.
This prevents an accidental wipe of an unrelated directory if the
install dir is ever misconfigured.
## How the hooks behave
### Stop hook (`mempalace-save`)
Fires every time the agent's execution loop terminates. Counts each
fire per-conversation; on every Nth fire (default 15, configurable
via `MEMPAL_SAVE_INTERVAL`), it spawns
`mempalace mine <transcript-dir> --mode convos` in the background.
Defers when:
- `fullyIdle == false` — background commands are still running, the
transcript is in motion. Try again on the next Stop fire.
- `terminationReason == "error"` — the transcript may be corrupt.
- A previous save for this conversation is still running.
- Any kill switch (see below) is set.
The hook **always** returns `{}` to stdout — never
`{"decision": "continue"}`, which would force the agent into an
infinite re-execution loop.
### PreInvocation hook (`mempalace-wake`)
Fires before every model call, but is gated to `invocationNum == 1`
so memory only gets injected once per conversation (mimicking
Cursor's `sessionStart` semantics).
When the gate passes, runs `mempalace wake-up --wing <inferred>` with
a 500ms hard timeout and emits the verbatim output as an
`ephemeralMessage`. The injection lives for one turn only and never
persists into the transcript.
The wing is inferred from `workspacePaths[0]` (the first absolute
workspace path). If you have a multi-workspace conversation, the
first workspace wins.
## Kill switches
Any one of these silently disables both hooks:
| Knob | Value |
|-------------------------------------|--------------------------------------|
| `MEMPAL_DISABLE_HOOK` | `1`, `true`, `yes` |
| `MEMPALACE_HOOKS_AUTO_SAVE` | `false`, `0`, `no` |
| `~/.mempalace/config.json` | `{ "hooks": { "auto_save": false }}` |
| (remove `~/.mempalace/` entirely) | palace nuke = no-op hooks |
Each kill switch results in `{}` on stdout and exit 0 — the hook
becomes a no-op without removing the plugin.
## Performance budget
- The hook scripts are designed to return in under 100ms when the
kill switch trips or any gate fails.
- The Stop hook spawns mining in a detached background subprocess
(`nohup ... &`) so the hook itself returns immediately while the
mining proceeds.
- The PreInvocation hook enforces a 500ms hard cap on
`mempalace wake-up`. If the call doesn't return in time, the hook
emits `{}` and the conversation starts without injection rather
than blocking the user.
## Verifying installation
```bash
ls ~/.gemini/config/plugins/mempalace/
# expect: README.md hooks/ hooks.json mcp_config.json plugin.json skills/
cat ~/.gemini/config/plugins/mempalace/hooks.json
# absolute paths to the two hook scripts
mempalace-mcp --version
# binary on PATH
bash -n ~/.gemini/config/plugins/mempalace/hooks/*.sh
# no syntax errors
```
After restarting Antigravity:
1. The MCP store should list `mempalace` as a registered server.
2. Starting a fresh conversation should fire the wake hook — check
`~/.mempalace/hook_state/antigravity_hook.log` for an
`[event=preInvocation]` line.
3. Ending a turn should fire the save hook — same log.
## Troubleshooting
### "MCP server `mempalace` not found"
The plugin file is in place but the binary isn't on `$PATH`:
```bash
mempalace-mcp --version
# command not found?
```
Install via uv (recommended) or pip:
```bash
uv tool install mempalace
# or
pip install mempalace
```
### Hooks aren't firing
Check the antigravity hook log:
```bash
tail -50 ~/.mempalace/hook_state/antigravity_hook.log
```
Each fire writes a line. No lines = the hook is not being invoked.
Verify `~/.gemini/config/plugins/mempalace/hooks.json` exists and the
`command` paths point to executable files.
### Save fires but no mining happens
Mining only triggers when `count % MEMPAL_SAVE_INTERVAL == 0`. The
log shows the running counter and interval per fire — wait for the
next save tick or set `MEMPAL_SAVE_INTERVAL=1` for testing.
### Wake injection isn't appearing
The wake hook is gated to `invocationNum == 1` AND only injects once
per conversation (atomic `mkdir` marker). Check
`~/.mempalace/hook_state/antigravity_woke_<conversationId>` exists
after a successful injection.
For a manual re-test:
```bash
rm -rf ~/.mempalace/hook_state/antigravity_woke_*
```
## See also
- [`hooks/antigravity/INVESTIGATION.md`](https://github.com/MemPalace/mempalace/blob/main/hooks/antigravity/INVESTIGATION.md)
— every Antigravity surface investigated, with verbatim quotes from
the official docs.
- [`hooks/antigravity/STDIN_SHAPE.md`](https://github.com/MemPalace/mempalace/blob/main/hooks/antigravity/STDIN_SHAPE.md)
— exact wire format for both events.
- [`examples/antigravity/`](https://github.com/MemPalace/mempalace/tree/main/examples/antigravity)
— standalone `hooks.json` + `mcp_config.json` for users who don't
want the full plugin install.
- [Auto-Save Hooks](./hooks.md) — Claude Code equivalent.
- [Gemini CLI](./gemini-cli.md) — Gemini CLI integration (separate from Antigravity).