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:
parent
db1fbe888b
commit
bf156fb010
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"mempalace-save": {
|
||||
"Stop": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "__PLUGIN_DIR__/hooks/mempal_save_hook_antigravity.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
"mempalace-wake": {
|
||||
"PreInvocation": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "__PLUGIN_DIR__/hooks/mempal_wake_hook_antigravity.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"name": "mempalace"
|
||||
}
|
||||
|
|
@ -0,0 +1,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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
# MemPalace — Antigravity examples
|
||||
|
||||
Two standalone configs for users who don't want to use the
|
||||
`hooks/antigravity/install.sh` installer.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|-------------------|-----------------------------------------------------------------------------------|
|
||||
| `hooks.json` | Standalone `hooks.json` registering the Stop and PreInvocation hooks. |
|
||||
| `mcp_config.json` | Standalone MCP entry registering the `mempalace-mcp` stdio server. |
|
||||
|
||||
## Wire up `hooks.json`
|
||||
|
||||
The example uses placeholder absolute paths (`/ABSOLUTE/PATH/TO/mempalace/...`).
|
||||
You must rewrite both `command` fields to the actual absolute paths to
|
||||
the hook scripts in your cloned repo, or to whichever location holds
|
||||
them. Antigravity will not resolve relative paths reliably.
|
||||
|
||||
Then drop the file at one of:
|
||||
|
||||
- `~/.gemini/config/hooks.json` (global, applies to every workspace)
|
||||
- `<workspace>/.agents/hooks.json` (workspace-scoped)
|
||||
|
||||
Restart Antigravity to pick the file up.
|
||||
|
||||
If you'd rather have the paths absolutized automatically, run the
|
||||
installer:
|
||||
|
||||
```bash
|
||||
bash hooks/antigravity/install.sh
|
||||
```
|
||||
|
||||
That writes a fully rendered `hooks.json` to
|
||||
`~/.gemini/config/plugins/mempalace/hooks.json`.
|
||||
|
||||
## Wire up `mcp_config.json`
|
||||
|
||||
The example registers the `mempalace-mcp` stdio server. Two options:
|
||||
|
||||
### Option A — merge into the user-level Antigravity MCP config
|
||||
|
||||
Antigravity's user-level MCP config lives at
|
||||
`~/.gemini/antigravity/mcp_config.json`. Merge the `mcpServers.mempalace`
|
||||
entry from this example into that file, then restart Antigravity.
|
||||
|
||||
### Option B — drop into a plugin directory
|
||||
|
||||
If you've created a custom plugin folder (per the [Antigravity plugins docs](https://antigravity.google/docs/plugins)),
|
||||
copy this `mcp_config.json` directly into the plugin root:
|
||||
|
||||
```
|
||||
<plugin-root>/mcp_config.json
|
||||
```
|
||||
|
||||
Antigravity merges plugin-level MCP entries with the user-level config
|
||||
on launch.
|
||||
|
||||
## Verify
|
||||
|
||||
After wiring up either or both:
|
||||
|
||||
```bash
|
||||
mempalace-mcp --version # confirm binary is on PATH
|
||||
ls ~/.mempalace/ # confirm palace exists (run `mempalace init` if not)
|
||||
```
|
||||
|
||||
Restart Antigravity. The `mempalace` MCP server should appear in the
|
||||
MCP store; the Stop and PreInvocation hooks fire automatically.
|
||||
|
||||
See [`hooks/antigravity/STDIN_SHAPE.md`](../../hooks/antigravity/STDIN_SHAPE.md)
|
||||
for the exact wire format Antigravity uses, and
|
||||
[`website/guide/antigravity.md`](../../website/guide/antigravity.md)
|
||||
for the full user-facing guide.
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"mempalace-save": {
|
||||
"Stop": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/ABSOLUTE/PATH/TO/mempalace/hooks/antigravity/mempal_save_hook_antigravity.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"mempalace-wake": {
|
||||
"PreInvocation": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/ABSOLUTE/PATH/TO/mempalace/hooks/antigravity/mempal_wake_hook_antigravity.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"mempalace": {
|
||||
"command": "mempalace-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
# Antigravity IDE — Integration Surface Investigation
|
||||
|
||||
**Investigated**: 2026-05-27
|
||||
**Author**: undeadindustries
|
||||
**Scope**: What MemPalace can integrate with in Google's Antigravity IDE,
|
||||
what we shipped, and what we deliberately did not ship.
|
||||
|
||||
This document is the source of truth for design decisions in the
|
||||
`feat/antigravity-support` branch. It exists so a future maintainer can
|
||||
re-derive every choice without re-reading the docs cold.
|
||||
|
||||
---
|
||||
|
||||
## 1. Surfaces verified against Google's official Antigravity docs
|
||||
|
||||
All quotes are pulled verbatim from Google's official Antigravity
|
||||
documentation on 2026-05-27. URLs are the authoritative source; the
|
||||
mirrored excerpts here are for reviewer convenience.
|
||||
|
||||
### 1.1. MCP — `https://antigravity.google/docs/mcp`
|
||||
|
||||
> The configuration file is located at `~/.gemini/antigravity/mcp_config.json`.
|
||||
>
|
||||
> The configuration file has a single `mcpServers` object where you
|
||||
> define each server you want to connect to.
|
||||
|
||||
Schema (verified):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"<name>": {
|
||||
"command": "...", // stdio
|
||||
"args": [...],
|
||||
"env": {...},
|
||||
"cwd": "...",
|
||||
"serverUrl": "...", // remote
|
||||
"headers": {...},
|
||||
"authProviderType": "google_credentials",
|
||||
"oauth": {"clientId": "...", "clientSecret": "..."},
|
||||
"disabled": false,
|
||||
"disabledTools": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-plugin form: `mcp_config.json` at the plugin root, same shape.
|
||||
Antigravity merges plugin entries with the user's
|
||||
`~/.gemini/antigravity/mcp_config.json` rather than clobbering.
|
||||
|
||||
**Cross-checked locally**: the user's existing
|
||||
`~/.gemini/antigravity/mcp_config.json` already contains a working
|
||||
`"mempalace": {"command": "/Users/robs/.local/bin/mempalace-mcp"}`
|
||||
entry, proving the shape matches and the binary is on PATH.
|
||||
|
||||
### 1.2. Plugins — `https://antigravity.google/docs/plugins`
|
||||
|
||||
> A plugin is a directory containing a `plugin.json` file and optional
|
||||
> subdirectories for different customization types:
|
||||
>
|
||||
> ```
|
||||
> plugins/<plugin-name>/
|
||||
> ├── plugin.json # Required marker file
|
||||
> ├── mcp_config.json # Optional MCP server definitions
|
||||
> ├── hooks.json # Optional hooks definition
|
||||
> ├── skills/ # Optional skills
|
||||
> │ └── <skill-name>/
|
||||
> │ └── SKILL.md
|
||||
> └── rules/ # Optional rules
|
||||
> └── <rule-name>.md
|
||||
> ```
|
||||
|
||||
Manifest schema (verified):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-custom-plugin"
|
||||
}
|
||||
```
|
||||
|
||||
> The name field is optional and defaults to the directory name if omitted.
|
||||
|
||||
Install locations (verified):
|
||||
|
||||
> - Workspace Level: Place your plugin folder inside a
|
||||
> `.agents/plugins/` or `_agents/plugins/` directory at the root of
|
||||
> your opened workspace.
|
||||
> - Global Level: Place your plugin folder inside
|
||||
> `~/.gemini/config/plugins/` in your user home directory.
|
||||
|
||||
We ship the global location as the canonical install path.
|
||||
|
||||
### 1.3. Skills — `https://antigravity.google/docs/skills`
|
||||
|
||||
> A skill is a folder containing a `SKILL.md` file with instructions
|
||||
> that the agent can follow when working on specific tasks.
|
||||
|
||||
Frontmatter (verified):
|
||||
|
||||
| Field | Required | Description |
|
||||
|---------------|----------|---------------------------------------------------------------------------------------------------|
|
||||
| `name` | No | A unique identifier (lowercase, hyphens). Defaults to the folder name. |
|
||||
| `description` | Yes | A clear description of what the skill does and when to use it. |
|
||||
|
||||
Standalone discovery paths (verified):
|
||||
|
||||
| Location | Scope |
|
||||
|-------------------------------------|------------------------|
|
||||
| `<workspace>/.agents/skills/` | Workspace-specific |
|
||||
| `~/.gemini/antigravity/skills/` | Global, all workspaces |
|
||||
|
||||
In-plugin discovery: `<plugin>/skills/<skill-name>/SKILL.md`.
|
||||
|
||||
We ship the in-plugin form so a single install registers MCP, skill, and
|
||||
hooks together.
|
||||
|
||||
### 1.4. Hooks — `https://antigravity.google/docs/hooks?app=antigravity`
|
||||
|
||||
> Hooks allow you to run custom scripts or shell commands at specific
|
||||
> points during Antigravity's execution loop.
|
||||
|
||||
`hooks.json` lives at one of:
|
||||
- `~/.gemini/config/hooks.json` (global)
|
||||
- `<workspace>/.agents/hooks.json` (workspace)
|
||||
- `<plugin-root>/hooks.json` (per-plugin) — what we ship
|
||||
|
||||
Top-level schema (verified):
|
||||
|
||||
```json
|
||||
{
|
||||
"<hook-name>": {
|
||||
"enabled": true,
|
||||
"PreToolUse": [{ "matcher": "...", "hooks": [{...}] }],
|
||||
"PostToolUse": [{ "matcher": "...", "hooks": [{...}] }],
|
||||
"PreInvocation": [{ ... }],
|
||||
"PostInvocation": [{ ... }],
|
||||
"Stop": [{ ... }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For `PreInvocation` / `PostInvocation` / `Stop`, items are flat handler
|
||||
objects; the `matcher` wrapper is only used for `PreToolUse` /
|
||||
`PostToolUse`.
|
||||
|
||||
Handler object (verified):
|
||||
|
||||
| Field | Required | Description |
|
||||
|-----------|----------|---------------------------------------------------|
|
||||
| `type` | No | Currently only `"command"` is supported. Default. |
|
||||
| `command` | Yes | The shell command to execute. |
|
||||
| `timeout` | No | Timeout in seconds. Defaults to 30. |
|
||||
|
||||
#### STDIN/STDOUT contract (verified)
|
||||
|
||||
> Hooks receive input via stdin as JSON and should return output via
|
||||
> stdout as JSON. Field names use camelCase.
|
||||
|
||||
Common stdin fields (every event):
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------------------|-----------------|-------------------------------------------------------|
|
||||
| `conversationId` | string | The unique UUID of the active agent conversation. |
|
||||
| `workspacePaths` | array<string> | Absolute directory paths of the user's workspaces. |
|
||||
| `transcriptPath` | string | Absolute path to the persistent `transcript.jsonl`. |
|
||||
| `artifactDirectoryPath` | string | Path to conversation artifacts and screenshots. |
|
||||
|
||||
`Stop` event additional fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------------|---------|----------------------------------------------------------------------------|
|
||||
| `executionNum` | integer | Sequence number of the execution attempt. |
|
||||
| `terminationReason` | string | `"model_stop"`, `"max_steps_exceeded"`, `"error"`, etc. |
|
||||
| `error` | string | Optional error message. |
|
||||
| `fullyIdle` | boolean | **Required.** True iff all background commands and async tasks are done. |
|
||||
|
||||
`Stop` event stdout (verified):
|
||||
|
||||
| Field | Type | Description |
|
||||
|------------|--------|-------------------------------------------------------------------------------------------------------|
|
||||
| `decision` | string | **Required.** Set to `"continue"` to FORCE the agent to keep running. Anything else allows the stop. |
|
||||
| `reason` | string | Optional. If `decision == "continue"`, injected as a system message. |
|
||||
|
||||
**CRITICAL**: emitting `{"decision": "continue"}` from a save hook would
|
||||
turn it into an infinite agent-loop trigger. The MemPalace save hook
|
||||
MUST emit `{}` on every code path. There is an explicit refusal in
|
||||
`mempal_save_hook_antigravity.sh` to ever print the literal word
|
||||
`"continue"` from a decision field.
|
||||
|
||||
`PreInvocation` event additional fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------------|---------|----------------------------------------------------------|
|
||||
| `invocationNum` | integer | Sequence number of the current model invocation. |
|
||||
| `initialNumSteps` | integer | Number of steps currently in the trajectory. |
|
||||
|
||||
`PreInvocation` event stdout (verified):
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------------|----------------|-----------------------------------------------------------------------------------------------|
|
||||
| `injectSteps` | array<object> | Optional. Steps injected before the model is called. Each step has one of: |
|
||||
| | | `{ "toolCall": {...} }` / `{ "userMessage": "..." }` / `{ "ephemeralMessage": "..." }` |
|
||||
|
||||
We use `ephemeralMessage` for the wake-up injection: the message is
|
||||
visible to the model on this turn but does not persist to the
|
||||
transcript, so we do not pollute future model calls with the same
|
||||
injection.
|
||||
|
||||
`PreInvocation` fires before EVERY model invocation, not only at session
|
||||
start. We gate to `invocationNum == 1` to mimic Cursor's `sessionStart`
|
||||
semantics — exactly one wake injection per conversation.
|
||||
|
||||
### 1.5. Permissions — `https://antigravity.google/docs/permissions`
|
||||
|
||||
Permissions are user-side, configured via Allow / Deny / Ask lists.
|
||||
Plugins do **not** declare permissions in `plugin.json`. The
|
||||
third-party "antigravity-plugins" community skill at
|
||||
`~/.gemini/skills/antigravity-plugins/SKILL.md` documents a
|
||||
`"permissions": [...]` field in `plugin.json`; that field is fabricated
|
||||
and does not appear in any real Google-shipped plugin (`firebase`,
|
||||
`google-antigravity-sdk`, `chrome-devtools-plugin`,
|
||||
`modern-web-guidance-plugin`) inspected at
|
||||
`~/.gemini/config/plugins/`.
|
||||
|
||||
We ship a minimal `plugin.json` of `{"name": "mempalace"}`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Surfaces shipped
|
||||
|
||||
| Surface | What we ship |
|
||||
|---------------------------|---------------------------------------------------------------------------|
|
||||
| Plugin manifest | `.antigravity-plugin/plugin.json` — minimal, verified shape |
|
||||
| MCP auto-registration | `.antigravity-plugin/mcp_config.json` — registers `mempalace-mcp` stdio |
|
||||
| Skill | `.antigravity-plugin/skills/mempalace/SKILL.md` — real file, frontmatter |
|
||||
| `Stop` hook | `hooks/antigravity/mempal_save_hook_antigravity.sh` — counter + auto-mine |
|
||||
| `PreInvocation` hook | `hooks/antigravity/mempal_wake_hook_antigravity.sh` — wake injection |
|
||||
| Installer | `hooks/antigravity/install.sh` — idempotent, basename-match uninstall |
|
||||
| User-facing docs | `website/guide/antigravity.md` + sidebar wiring |
|
||||
| Examples | `examples/antigravity/{hooks.json,mcp_config.json,README.md}` |
|
||||
| Tests | 3 test files mirroring the cursor blueprint |
|
||||
|
||||
---
|
||||
|
||||
## 3. Surfaces deliberately not shipped
|
||||
|
||||
### 3.1. `PreCompact` equivalent — NOT SHIPPED
|
||||
|
||||
Antigravity's external `hooks.json` does **not** expose a context-compaction event. The Python SDK has an in-process `@hooks.on_compaction`
|
||||
decorator (see `~/.gemini/config/plugins/google-antigravity-sdk/examples/getting_started/hooks.md`),
|
||||
but that fires inside a Python `LocalAgentConfig`-built agent, not the
|
||||
IDE itself. There is no way to subscribe to compaction from a
|
||||
plugin's `hooks.json`.
|
||||
|
||||
UX consequence: long conversations can auto-compact without a save
|
||||
checkpoint. The `Stop` hook still catches the conversation when the
|
||||
user actually ends the turn, so the worst case is that some mid-turn
|
||||
state is lost on auto-compaction. Verbatim transcript ingestion via
|
||||
the `Stop` path covers the long-term recall use case.
|
||||
|
||||
### 3.2. Slash-commands / `commands/` — NOT SHIPPED
|
||||
|
||||
Antigravity has no `commands/` plugin component. The Cursor and Codex
|
||||
integrations both ship five quick-reference commands
|
||||
(`mempalace-help`, `-init`, `-mine`, `-search`, `-status`) that point
|
||||
at `mempalace instructions <cmd>`. Those have been folded into the
|
||||
`SKILL.md` `## Common operations` section so the agent gets the same
|
||||
quick-reference content via Antigravity's progressive-disclosure skill
|
||||
loading. No new files, no rule-noise, same discoverability.
|
||||
|
||||
### 3.3. `rules/` — NOT SHIPPED
|
||||
|
||||
`rules/<name>.md` files are evaluated as constraints on the agent's
|
||||
behavior. Shipping rules from MemPalace risks colliding with the
|
||||
user's existing project rules (e.g. `.agents/rules/*.md` files the
|
||||
user has already authored). Users who want strict MemPalace-related
|
||||
rules can drop them into their own `<workspace>/.agents/rules/`
|
||||
directory; we do not impose them.
|
||||
|
||||
### 3.4. Workspace-level `.agents/plugins/mempalace/` install — NOT SHIPPED BY DEFAULT
|
||||
|
||||
The installer writes to the global location at
|
||||
`~/.gemini/config/plugins/mempalace/`. Workspace-scoped installs are
|
||||
documented in `hooks/antigravity/README.md` for users who want to
|
||||
limit MemPalace to one workspace; they can `cp -r .antigravity-plugin
|
||||
<workspace>/.agents/plugins/mempalace`. We do not install there
|
||||
automatically because the canonical UX is global.
|
||||
|
||||
### 3.5. `permissions` field in `plugin.json` — NOT SHIPPED
|
||||
|
||||
Antigravity permissions are user-side (`Allow` / `Deny` / `Ask`
|
||||
lists). Plugin manifests do not declare permissions. The
|
||||
`"permissions": [...]` field documented in the third-party
|
||||
"antigravity-plugins" community skill is fabricated; no
|
||||
Google-shipped plugin uses it.
|
||||
|
||||
### 3.6. `PreToolUse` / `PostToolUse` hooks — NOT SHIPPED
|
||||
|
||||
These would let MemPalace observe every tool call (e.g.
|
||||
auto-extract entities after each `write_to_file`). Out of scope
|
||||
for v1; the hook surface is real and could be added in a future
|
||||
PR if there is demand. Documenting the omission here so a future
|
||||
contributor doesn't conclude the hooks weren't supported.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-checks against the user's running Antigravity
|
||||
|
||||
The user (`robs@`) has Antigravity 2.0 installed. The following live
|
||||
artifacts on this machine corroborate the published docs:
|
||||
|
||||
| Path | Confirms |
|
||||
|------------------------------------------------------------------------|----------------------------------------------------------------|
|
||||
| `~/.gemini/antigravity/mcp_config.json` | MCP config path + standard `mcpServers` shape |
|
||||
| `~/.gemini/antigravity/skills/<skill-name>/SKILL.md` (multiple) | Global skill discovery path |
|
||||
| `~/.gemini/config/plugins/firebase/plugin.json` | Real `plugin.json` shape (no `permissions` field) |
|
||||
| `~/.gemini/config/plugins/chrome-devtools-plugin/skills/.../SKILL.md` | In-plugin skill discovery `<plugin>/skills/<name>/SKILL.md` |
|
||||
| `~/.gemini/config/plugins/google-antigravity-sdk/examples/.../hooks.md` | SDK-side compaction hook is in-process Python only |
|
||||
| Existing `mempalace` entry in `mcp_config.json` | `mempalace-mcp` already running and discoverable |
|
||||
|
||||
---
|
||||
|
||||
## 5. Reference URLs (all 2026-05-27)
|
||||
|
||||
- `https://antigravity.google/docs/plugins`
|
||||
- `https://antigravity.google/docs/hooks?app=antigravity`
|
||||
- `https://antigravity.google/docs/skills`
|
||||
- `https://antigravity.google/docs/mcp`
|
||||
- `https://antigravity.google/docs/permissions`
|
||||
- `https://antigravity.google/docs/subagents`
|
||||
- `https://antigravity.google/blog/introducing-google-antigravity-sdk`
|
||||
|
|
@ -0,0 +1,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.
|
||||
|
|
@ -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. |
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
Loading…
Reference in New Issue