feat(zcode): add Hindsight long-term memory integration for ZCode (#2549)

* feat(zcode): add Hindsight long-term memory integration for ZCode

Adds a hooks-based, no-MCP integration for ZCode (Z.ai's GLM desktop
coding agent). ZCode embeds the Claude Code agent runtime and reads the
standard Claude Code hook schema from its own config namespace
(~/.zcode/cli/config.json), so `hindsight-zcode install` wires three
process hooks — SessionStart, UserPromptSubmit (recall), and Stop
(retain) — without touching the user's ~/.claude config and without an
MCP server.

Recall injects relevant memories as additionalContext before each
prompt; retain assembles each turn from the prompt (captured at
UserPromptSubmit) and the response (Stop payload) and stores it to
Hindsight. Verified end-to-end in ZCode 3.2.2: hooks fire, retain
persists to the cloud bank, and recall injects memory into the agent.

Includes the pip package + installer, hook scripts, tests, CI job,
release-integration wiring, changelog registration, docs page, and
gallery entry.

* feat(zcode): add self-serve marketplace + hooks-only plugin variant

Publishes the ZCode integration as a hooks-only Claude Code plugin
(hindsight-zcode) in the repo's plugin marketplace, so ZCode users can
install it via 'zcode plugins add-marketplace vectorize-io/hindsight'
without pip and without depending on Z.ai's marketplace.

The plugin reuses the pip package's hook scripts via CLAUDE_PLUGIN_ROOT
(no duplication) — settings.json resolves as a sibling of scripts/ in
both the pip and plugin layouts. Adds a plugin manifest, plugin-format
hooks.json (SessionStart/UserPromptSubmit/Stop — no SessionEnd),
marketplace entry, validation tests, and docs.

* fix(zcode): drop changelog link from docs page (page exists only after release)

The /changelog/integrations/zcode page is generated at release time, so
linking to it broke the Docusaurus build (build-docs + verify-generated-files).
Most unreleased integration pages omit this link; follow that convention.
This commit is contained in:
Ben
2026-07-20 10:58:24 -04:00
committed by GitHub
parent 11154d48b7
commit b11e053323
40 changed files with 4486 additions and 1 deletions
+5
View File
@@ -11,6 +11,11 @@
"name": "hindsight-memory",
"description": "Automatic long-term memory for Claude Code via Hindsight",
"source": "./hindsight-integrations/claude-code"
},
{
"name": "hindsight-zcode",
"description": "No-MCP long-term memory for ZCode via Hindsight hooks",
"source": "./hindsight-integrations/zcode"
}
]
}
+41
View File
@@ -41,6 +41,7 @@ jobs:
integrations-github-copilot: ${{ steps.filter.outputs.integrations-github-copilot }}
integrations-continue: ${{ steps.filter.outputs.integrations-continue }}
integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }}
integrations-zcode: ${{ steps.filter.outputs.integrations-zcode }}
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
@@ -180,6 +181,8 @@ jobs:
- 'hindsight-integrations/cursor/**'
integrations-zed:
- 'hindsight-integrations/zed/**'
integrations-zcode:
- 'hindsight-integrations/zcode/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-zapier:
@@ -697,6 +700,43 @@ jobs:
working-directory: ./hindsight-integrations/cursor-cli
run: uv run pytest tests -v
test-zcode-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-zcode == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build zcode integration
working-directory: ./hindsight-integrations/zcode
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/zcode
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/zcode
run: uv run pytest tests -v
build-ai-sdk-integration:
needs: [detect-changes]
if: >-
@@ -4905,6 +4945,7 @@ jobs:
- test-github-copilot-integration
- test-codex-integration
- test-cursor-cli-integration
- test-zcode-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
@@ -50,6 +50,7 @@ INTEGRATIONS: dict[str, IntegrationMeta] = {
"nemoclaw": IntegrationMeta("@vectorize-io/hindsight-nemoclaw", "NemoClaw"),
"strands": IntegrationMeta("hindsight-strands", "Strands"),
"claude-code": IntegrationMeta("hindsight-memory", "Claude Code"),
"zcode": IntegrationMeta("hindsight-zcode", "ZCode"),
"claude-agent-sdk": IntegrationMeta("hindsight-claude-agent-sdk", "Claude Agent SDK"),
"llamaindex": IntegrationMeta("hindsight-llamaindex", "LlamaIndex"),
"codex": IntegrationMeta("hindsight-codex", "Codex"),
+169
View File
@@ -0,0 +1,169 @@
---
sidebar_position: 40
title: "ZCode Persistent Memory with Hindsight | Integration Guide"
description: "Add persistent long-term memory to ZCode (Z.ai's GLM desktop coding agent) with Hindsight. Python hooks automatically recall context before each prompt and retain conversations — no MCP, no workflow changes."
---
# ZCode
Persistent memory for [ZCode](https://zcode.z.ai) — Z.ai's GLM desktop coding agent — using [Hindsight](https://vectorize.io/hindsight). ZCode embeds the Claude Code agent runtime, so Python hook scripts automatically recall relevant context before each prompt and retain conversations after each turn. No MCP server, no changes to your ZCode workflow.
## Quick Start
:::tip Recommended: Hindsight Cloud
[Sign up free](https://ui.hindsight.vectorize.io/signup) for a Hindsight Cloud API key — no self-hosting, no local daemon to manage.
:::
```bash
# Install the CLI
pip install hindsight-zcode
# Install the hooks (defaults to Hindsight Cloud)
hindsight-zcode install --api-url https://api.hindsight.vectorize.io --api-token your-api-key
# Restart ZCode — memory is live
```
The installer copies the hook scripts to `~/.zcode/hooks/hindsight/`, registers them in `~/.zcode/cli/config.json` (merged with any existing hooks), and creates `~/.hindsight/zcode.json` for your personal config. It never touches your Claude Code config at `~/.claude/settings.json`.
**Self-hosting alternative** — connect to a local `hindsight-embed` daemon by omitting the flags:
```bash
hindsight-zcode install
```
To uninstall:
```bash
hindsight-zcode uninstall
```
### Alternative: install as a ZCode plugin
ZCode can install Hindsight directly from a plugin marketplace — no `pip` step. The same hook scripts ship as a hooks-only Claude Code plugin (`hindsight-zcode`) in the Hindsight marketplace:
```
# In ZCode: add the Hindsight marketplace, then install the plugin
zcode plugins add-marketplace vectorize-io/hindsight
zcode plugins install hindsight-zcode
```
When installed this way, ZCode registers the hooks automatically (no config-file edit). Provide your Hindsight credentials via environment variables (`HINDSIGHT_API_URL`, `HINDSIGHT_API_TOKEN`) or by creating `~/.hindsight/zcode.json`:
```json
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "hsk_your_token"
}
```
## Features
- **Auto-recall** — before each prompt, queries Hindsight for relevant memories and injects them as additional context (visible to the model, not the transcript)
- **Auto-retain** — after each response, stores the turn to Hindsight for future recall
- **No MCP required** — plain Python hook scripts calling Hindsight's REST API; nothing to run alongside ZCode
- **Cross-tool memory** — the same Hindsight bank is shared across Claude Code, Cursor, and other Hindsight integrations, so memory follows you between tools
- **Dynamic bank IDs** — supports per-project memory isolation based on the working directory
- **Zero runtime dependencies** — the hook scripts are pure Python stdlib; the `pip install` only ships the one-time installer
## Architecture
ZCode embeds the Claude Code agent runtime and reads the standard Claude Code hook schema from its own config namespace, `~/.zcode/cli/config.json` (with `hooks.enabled: true`). The plugin wires three hook events:
| Hook | Event | Purpose |
|------|-------|---------|
| `session_start.py` | `SessionStart` | Warm up — verify Hindsight is reachable |
| `recall.py` | `UserPromptSubmit` | **Auto-recall** — query memories, inject as `additionalContext` |
| `retain.py` | `Stop` | **Auto-retain** — assemble the turn, POST to Hindsight |
On `UserPromptSubmit`, the hook reads the prompt, queries Hindsight for the most relevant memories, and emits a context block that ZCode injects before sending the turn to the model:
```
<hindsight_memories>
Relevant memories from past conversations...
Current time - 2026-03-27 09:14
- Project uses FastAPI with asyncpg — not SQLAlchemy [world] (2026-03-26)
- Preferred testing framework: pytest with pytest-asyncio [experience] (2026-03-26)
</hindsight_memories>
```
On `Stop`, the hook pairs the user prompt (captured at `UserPromptSubmit`) with the agent's response and POSTs the turn to Hindsight. ZCode does not provide a `SessionEnd` hook event, so retention rides `Stop` — every turn is stored as it completes.
## Connection Modes
### 1. External API (recommended)
Connect to a running Hindsight server (cloud or self-hosted) via `~/.hindsight/zcode.json`:
```json
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "hsk_your_token"
}
```
### 2. Local Daemon
Run `hindsight-embed` locally. The `session_start.py` hook detects it on `apiPort` (default `9077`). The daemon is not auto-started by the plugin — start it separately:
```bash
uvx hindsight-embed
```
Then leave `hindsightApiUrl` empty in your config and the plugin connects to `http://localhost:9077`.
## Configuration
Default config ships in `~/.zcode/hooks/hindsight/settings.json`. For personal overrides that survive updates, create `~/.hindsight/zcode.json`. Most settings can also be overridden via environment variable.
**Loading order** (later entries win):
1. Built-in defaults
2. Plugin `settings.json` (at `~/.zcode/hooks/hindsight/settings.json`)
3. User config (`~/.hindsight/zcode.json`)
4. Environment variables
---
### Connection
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | `HINDSIGHT_API_URL` | `""` | URL of the Hindsight API server. Empty = local daemon. |
| `hindsightApiToken` | `HINDSIGHT_API_TOKEN` | `null` | API token for authentication. Required for Hindsight Cloud. |
| `apiPort` | `HINDSIGHT_API_PORT` | `9077` | Port for the local `hindsight-embed` daemon. |
---
### Memory Bank
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `bankId` | `HINDSIGHT_BANK_ID` | `"zcode"` | The bank to read from and write to. All sessions share this bank unless `dynamicBankId` is enabled. |
| `bankMission` | `HINDSIGHT_BANK_MISSION` | coding assistant prompt | Describes the agent's purpose. Sent when creating or updating the bank. |
| `dynamicBankId` | `HINDSIGHT_DYNAMIC_BANK_ID` | `false` | When `true`, derives a unique bank ID from `dynamicBankGranularity` fields — useful for per-project isolation. |
| `agentName` | `HINDSIGHT_AGENT_NAME` | `"zcode"` | Agent name used in dynamic bank ID derivation. |
---
### Auto-Recall
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRecall` | `HINDSIGHT_AUTO_RECALL` | `true` | Master switch for auto-recall. |
| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Search depth: `"low"` (fast), `"mid"` (balanced), `"high"` (thorough). |
| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Token budget for the injected memory block. |
---
### Auto-Retain
| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRetain` | `HINDSIGHT_AUTO_RETAIN` | `true` | Master switch for auto-retain. |
| `retainEveryNTurns` | `HINDSIGHT_RETAIN_EVERY_N_TURNS` | `1` | Retain every N turns. Default `1` stores every turn on `Stop`. |
## Relationship to ZCode's built-in memory
ZCode ships its own local, per-project memory (`~/.zcode/cli/memories/`). Hindsight is complementary: it stores memory in a **cloud (or self-hosted) bank that is shared across tools** — the same bank powers Claude Code, Cursor, and other Hindsight integrations — so your context follows you between agents and machines rather than staying local to one ZCode project.
+10
View File
@@ -140,6 +140,16 @@
"link": "/sdks/integrations/claude-code",
"icon": "/img/icons/claude-code.png"
},
{
"id": "zcode",
"name": "ZCode",
"description": "No-MCP long-term memory for ZCode (Z.ai's GLM desktop coding agent) via Hindsight hooks. Recalls relevant context before each prompt and retains conversations after each turn.",
"type": "official",
"by": "hindsight",
"category": "tool",
"link": "/sdks/integrations/zcode",
"icon": "/img/icons/zcode.svg"
},
{
"id": "claude-agent-sdk",
"name": "Claude Agent SDK",
+10
View File
@@ -0,0 +1,10 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="zcodeA" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#4F7CFF"/>
<stop offset="1" stop-color="#1BC7B4"/>
</linearGradient>
</defs>
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#zcodeA)"/>
<path d="M22 22h20v5.2L28.4 42H42v6H22v-5.2L35.6 28H22z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

@@ -0,0 +1,8 @@
{
"name": "hindsight-zcode",
"description": "No-MCP long-term memory for ZCode (and Claude Code) via Hindsight hooks. Recalls relevant context before each prompt and retains conversations after each turn.",
"version": "0.1.0",
"author": {"name": "Hindsight Team", "url": "https://vectorize.io/hindsight"},
"license": "MIT",
"keywords": ["memory", "hindsight", "recall", "retain", "zcode", "glm"]
}
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.ruff_cache/
.venv/
dist/
*.egg-info/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Vectorize
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+140
View File
@@ -0,0 +1,140 @@
# Hindsight for ZCode
Long-term memory for [ZCode](https://zcode.z.ai) — Z.ai's GLM desktop coding agent. Remembers your projects, preferences, and past sessions across every conversation.
ZCode ships a native process-hook system, so Hindsight plugs in through hooks — no MCP server required. The installer writes to ZCode's CLI config (`~/.zcode/cli/config.json`), never your real Claude Code config, and enables config hooks (off by default).
## How it works
Three ZCode hooks keep memory in sync automatically:
| Hook | Action |
|------|--------|
| `SessionStart` | Confirms Hindsight is reachable and pre-warms the local daemon if needed |
| `UserPromptSubmit` | Recalls relevant memories (injected via `hookSpecificOutput.additionalContext`) and stashes the prompt for the next retain |
| `Stop` | Pairs the stashed prompt with the assistant reply and retains the turn to long-term memory |
ZCode has no `SessionEnd` event, so retain rides `Stop`. Each turn is stored as its own memory (distinct `document_id`).
## Requirements
- **ZCode** with config-hooks support (`~/.zcode/cli/config.json`)
- **Python 3.9+** (for hook scripts; stdlib only — no pip install required)
- **Hindsight**: [Hindsight Cloud](https://hindsight.vectorize.io) or local `hindsight-embed`
## Installation
Sign up free at [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io/signup) for a Hindsight Cloud API key — or run a local server.
```bash
pip install hindsight-zcode
```
Then run the installer once:
```bash
# Hindsight Cloud
hindsight-zcode install --api-url https://api.hindsight.vectorize.io --api-token your-api-key
# Local daemon (hindsight-embed) — omit the flags
hindsight-zcode install
```
The installer:
1. Copies the hook scripts to `~/.zcode/hooks/hindsight/`
2. Merges Hindsight's hooks into `~/.zcode/cli/config.json` under `hooks.events` (preserving any existing keys and foreign hooks), sets `hooks.enabled` to `true`, and uses absolute paths to the scripts
3. Seeds `~/.hindsight/zcode.json` if it doesn't exist (drop your `hindsightApiToken` here later)
Restart ZCode to load the hooks. If memories are not recalled or retained, check that
`~/.zcode/cli/config.json` has `"hooks": {"enabled": true, ...}` with the Hindsight entries and that `python3` is on `$PATH` from your shell.
### Uninstall
```bash
hindsight-zcode uninstall
```
This removes the hook scripts and strips Hindsight's entries from `~/.zcode/cli/config.json`. Any other keys and foreign hooks in that file, and your personal config at `~/.hindsight/zcode.json`, are preserved.
## Configuration
Default config lives in `~/.zcode/hooks/hindsight/settings.json`. For personal overrides stable across updates, create `~/.hindsight/zcode.json`:
```json
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "your-api-key",
"bankId": "my-zcode-memory"
}
```
### Configuration options
| Key | Default | Description |
|-----|---------|-------------|
| `hindsightApiUrl` | `""` | External API URL (empty = local daemon) |
| `hindsightApiToken` | `null` | API token for Hindsight Cloud |
| `bankId` | `"zcode"` | Memory bank identifier |
| `bankMission` | (set) | Guides what facts Hindsight retains |
| `autoRecall` | `true` | Inject memories before each prompt |
| `autoRetain` | `true` | Store conversations after each turn |
| `retainMode` | `"full-session"` | `"full-session"` or `"chunked"` |
| `retainEveryNTurns` | `10` | Retain every N turns (1 = every turn) |
| `recallBudget` | `"mid"` | Recall depth: `"low"`, `"mid"`, `"high"` |
| `recallMaxTokens` | `1024` | Max tokens for injected memories |
| `recallTimeout` | `10` | Timeout in seconds for recall API calls |
| `dynamicBankId` | `false` | Separate bank per project |
| `dynamicBankGranularity` | `["agent", "project"]` | Fields for dynamic bank ID |
| `debug` | `false` | Log debug info to stderr |
### Environment variable overrides
All settings can also be set via environment variables:
```bash
export HINDSIGHT_API_URL=https://api.hindsight.vectorize.io
export HINDSIGHT_API_TOKEN=your-api-key
export HINDSIGHT_BANK_ID=my-project
export HINDSIGHT_RECALL_TIMEOUT=30
export HINDSIGHT_DEBUG=true
```
## How memory works
**Recall** — before each prompt, Hindsight searches your memory bank for facts relevant to what you're about to ask. Found memories are injected via the Claude Code `hookSpecificOutput.additionalContext` field so the agent has continuity across sessions.
**Retain** — after configured turns and again when the session ends, ZCode's conversation transcript is stored to Hindsight. The memory engine extracts facts, relationships, and experiences — so you don't need to re-explain your stack, preferences, or past decisions.
## Dynamic bank IDs
To keep separate memory per project:
```json
{
"dynamicBankId": true,
"dynamicBankGranularity": ["agent", "project"]
}
```
This creates banks like `zcode::my-project` automatically, using the hook's `cwd` (set by the Claude Code runtime), the optional `ZCODE_PROJECT_DIR` env var, or the first entry of `workspace_roots`.
## Troubleshooting
**Memory not appearing**: enable debug mode (`"debug": true`, or `HINDSIGHT_DEBUG=true`) and check that `HINDSIGHT_API_URL` points to a reachable server.
**Hooks not firing**: check that `~/.zcode/cli/config.json` is valid JSON, that `hooks.enabled` is `true`, and that the Hindsight entries are present under `hooks.events`. ZCode requires a session restart to pick up new hooks.
## Development
```bash
cd hindsight-integrations/zcode
uv sync
uv run pytest tests/ -v
```
The tests mock the HTTP client, the stdin/stdout pipe, and the file-based state. No live Hindsight server is required.
## License
MIT
@@ -0,0 +1,23 @@
"""Hindsight long-term memory integration for ZCode."""
from .install import (
get_config_path,
get_install_dir,
merge_hooks,
render_hooks_events,
run_install,
run_uninstall,
seed_user_config,
write_settings,
)
__all__ = [
"get_config_path",
"get_install_dir",
"merge_hooks",
"render_hooks_events",
"run_install",
"run_uninstall",
"seed_user_config",
"write_settings",
]
@@ -0,0 +1,6 @@
"""Enable ``python -m hindsight_zcode``."""
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,72 @@
"""Command-line interface for the ZCode Hindsight integration.
Exposed as the ``hindsight-zcode`` console script:
hindsight-zcode install
hindsight-zcode install --api-url https://api.hindsight.vectorize.io --api-token hsk_...
hindsight-zcode uninstall
"""
import argparse
import os
from .install import run_install, run_uninstall
DEFAULT_API_URL = "https://api.hindsight.vectorize.io"
def _run_install(args: argparse.Namespace) -> int:
run_install(api_url=args.api_url, api_token=args.api_token)
return 0
def _run_uninstall(_args: argparse.Namespace) -> int:
run_uninstall()
return 0
def _add_install_parser(subparsers: argparse._SubParsersAction) -> None:
install = subparsers.add_parser(
"install",
help="Install the Hindsight hook scripts into ZCode.",
)
install.add_argument(
"--api-url",
default=os.environ.get("HINDSIGHT_API_URL"),
help=(
"Hindsight API base URL written to ~/.hindsight/zcode.json. "
f"For Hindsight Cloud use {DEFAULT_API_URL}. "
"Omit to connect to a local hindsight-embed daemon."
),
)
install.add_argument(
"--api-token",
default=os.environ.get("HINDSIGHT_API_TOKEN"),
help="Hindsight API token (required for Hindsight Cloud).",
)
install.set_defaults(func=_run_install)
def _add_uninstall_parser(subparsers: argparse._SubParsersAction) -> None:
uninstall = subparsers.add_parser(
"uninstall",
help="Remove the Hindsight hook scripts from ZCode.",
)
uninstall.set_defaults(func=_run_uninstall)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="hindsight-zcode",
description="Install Hindsight long-term memory for ZCode.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
_add_install_parser(subparsers)
_add_uninstall_parser(subparsers)
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,38 @@
{
"SessionStart": [
{
"hooks": [
{
"type": "process",
"command": "python3",
"args": ["__SCRIPTS_DIR__/session_start.py"],
"timeoutMs": 5000
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "process",
"command": "python3",
"args": ["__SCRIPTS_DIR__/recall.py"],
"timeoutMs": 12000
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "process",
"command": "python3",
"args": ["__SCRIPTS_DIR__/retain.py"],
"timeoutMs": 15000
}
]
}
]
}
@@ -0,0 +1,129 @@
"""Bank ID derivation and mission management.
ZCode context dimensions:
- agent → configured name or "zcode" (HINDSIGHT_AGENT_NAME)
- project → derived from ZCODE_PROJECT_DIR / hook cwd / first workspace_roots
- user → from env var HINDSIGHT_USER_ID
ZCode embeds the Claude Code agent runtime, so every hook payload carries a
`cwd`, which is the primary project source. The channel dimension is omitted —
ZCode is a desktop coding agent without multi-channel routing like
Telegram/Discord agents.
"""
import os
import sys
from .state import read_state, write_state
DEFAULT_BANK_NAME = "zcode"
# Valid granularity fields for the ZCode integration.
# "session" is exposed but not part of the default granularity because the
# `stop` hook is fire-and-forget and doesn't carry a session_id by default.
VALID_FIELDS = {"agent", "project", "gitProject", "session", "user"}
def _resolve_project_name(hook_input):
"""Resolve the project name for bank-id derivation.
Priority:
1. ZCODE_PROJECT_DIR env var (optional explicit override)
2. hook_input.workspace_roots[0] (present in some payloads)
3. hook_input.cwd (the Claude Code runtime sets this on every hook)
Returns "unknown" when no project source is available so bank IDs
stay stable across test runners (avoids leaking the test runner's
cwd into the bank name).
"""
env_project = os.environ.get("ZCODE_PROJECT_DIR", "").strip()
if env_project:
return os.path.basename(env_project) or "unknown"
if isinstance(hook_input, dict):
roots = hook_input.get("workspace_roots")
if isinstance(roots, list) and roots:
first = roots[0]
if isinstance(first, str) and first:
return os.path.basename(first) or "unknown"
cwd = hook_input.get("cwd", "")
if cwd:
return os.path.basename(cwd) or "unknown"
return "unknown"
def _resolve_session_id(hook_input):
"""Resolve a stable session identifier from the hook payload."""
if not isinstance(hook_input, dict):
return "unknown"
return hook_input.get("session_id") or hook_input.get("conversation_id") or "unknown"
def derive_bank_id(hook_input, config):
"""Derive a bank ID from hook context and config.
When `dynamicBankId` is false, returns the static bank. When true,
composes from granularity fields joined by '::'.
"""
prefix = config.get("bankIdPrefix", "")
if not config.get("dynamicBankId", False):
base = config.get("bankId") or DEFAULT_BANK_NAME
return f"{prefix}-{base}" if prefix else base
fields = config.get("dynamicBankGranularity")
if not fields or not isinstance(fields, list):
fields = ["agent", "project"]
for f in fields:
if f not in VALID_FIELDS:
print(
f'[Hindsight] Unknown dynamicBankGranularity field "{f}"'
f"valid for zcode: {', '.join(sorted(VALID_FIELDS))}",
file=sys.stderr,
)
agent_name = config.get("agentName", "zcode")
user_id = os.environ.get("HINDSIGHT_USER_ID", "")
session_id = _resolve_session_id(hook_input)
project_name = _resolve_project_name(hook_input)
field_map = {
"agent": agent_name,
"project": project_name,
"gitProject": project_name, # alias for backwards-compat with codex configs
"session": session_id,
"user": user_id or "anonymous",
}
segments = [field_map.get(f, "unknown") for f in fields]
base_bank_id = "::".join(segments)
return f"{prefix}-{base_bank_id}" if prefix else base_bank_id
def ensure_bank_mission(client, bank_id, config, debug_fn=None):
"""Set bank mission on first use, skip if already set."""
mission = config.get("bankMission", "")
if not mission or not mission.strip():
return
missions_set = read_state("bank_missions.json", {})
if bank_id in missions_set:
return
try:
retain_mission = config.get("retainMission")
client.set_bank_mission(bank_id, mission, retain_mission=retain_mission, timeout=10)
missions_set[bank_id] = True
if len(missions_set) > 10000:
keys = sorted(missions_set.keys())
for k in keys[: len(keys) // 2]:
del missions_set[k]
write_state("bank_missions.json", missions_set)
if debug_fn:
debug_fn(f"Set mission for bank: {bank_id}")
except Exception as e:
if debug_fn:
debug_fn(f"Could not set bank mission for {bank_id}: {e}")
@@ -0,0 +1,157 @@
"""Hindsight REST API client (stdlib HTTP).
Mirrors `hindsight-integrations/codex/scripts/lib/client.py` with the
User-Agent string rebranded for the ZCode integration.
"""
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
DEFAULT_TIMEOUT = 15
HEALTH_CHECK_RETRIES = 3
HEALTH_CHECK_DELAY = 2
def _plugin_version():
"""Read the plugin version from settings.json (single source of truth)."""
manifest = Path(__file__).resolve().parents[2] / "settings.json"
try:
return json.loads(manifest.read_text()).get("version", "0.0.0")
except (OSError, ValueError):
return "0.0.0"
# Sent on every request so self-hosted deployments behind Cloudflare (or any
# reverse proxy with UA-based bot filtering) don't block the stdlib default
# "Python-urllib/X.Y", which trips Cloudflare error 1010.
USER_AGENT = f"hindsight-zcode/{_plugin_version()}"
def _validate_api_url(url):
"""Validate and normalize the API URL. Reject non-HTTP schemes."""
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Hindsight API URL must use http or https, got: {parsed.scheme!r}")
if not parsed.hostname:
raise ValueError(f"Hindsight API URL has no hostname: {url!r}")
return url.rstrip("/")
class HindsightClient:
"""HTTP client for the Hindsight API."""
def __init__(self, api_url, api_token=None):
self.api_url = _validate_api_url(api_url)
self.api_token = api_token
def _headers(self):
headers = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
}
if self.api_token:
headers["Authorization"] = f"Bearer {self.api_token}"
return headers
def _request(self, method, path, body=None, timeout=DEFAULT_TIMEOUT):
url = f"{self.api_url}{path}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body_text = ""
try:
body_text = e.read().decode()
except Exception:
pass
raise RuntimeError(f"HTTP {e.code} from {url}: {body_text}") from e
def health_check(self, timeout=5):
"""Check if the Hindsight server is reachable.
Mirrors codex's behavior: retries up to 3 times with 2s delay
between attempts.
"""
import time
for attempt in range(1, HEALTH_CHECK_RETRIES + 1):
try:
url = f"{self.api_url}/health"
req = urllib.request.Request(url, headers=self._headers(), method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status == 200:
return True
except Exception:
pass
if attempt < HEALTH_CHECK_RETRIES:
time.sleep(HEALTH_CHECK_DELAY)
return False
def recall(
self,
bank_id,
query,
max_tokens=1024,
budget="mid",
types=None,
timeout=10,
):
"""Recall memories from a bank. Returns the raw API response dict."""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories/recall"
body = {
"query": query,
"max_tokens": max_tokens,
}
if budget:
body["budget"] = budget
if types:
body["types"] = types
return self._request("POST", path, body, timeout=timeout)
def retain(
self,
bank_id,
content,
document_id="conversation",
context=None,
metadata=None,
tags=None,
timeout=15,
):
"""Retain content into a bank's memory.
Posts with async=true so the server processes in the background. The
`context` field helps Hindsight cluster memories by provenance
(e.g. "zcode" vs manual retains).
"""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
item = {
"content": content,
"document_id": document_id,
"metadata": metadata or {},
}
if context:
item["context"] = context
if tags:
item["tags"] = tags
body = {
"items": [item],
"async": True,
}
return self._request("POST", path, body, timeout=timeout)
def set_bank_mission(self, bank_id, mission, retain_mission=None, timeout=15):
"""Set the mission/persona for a bank.
Uses PATCH /banks/{id}/config with reflect_mission and retain_mission.
"""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/config"
updates = {"reflect_mission": mission}
if retain_mission:
updates["retain_mission"] = retain_mission
return self._request("PATCH", path, {"updates": updates}, timeout=timeout)
@@ -0,0 +1,150 @@
"""Configuration management for the Hindsight ZCode integration.
Mirrors `hindsight-integrations/cursor-cli/hindsight_cursor_cli/hooks/scripts/lib/config.py`
with the agent name and a couple of defaults rebranded for the ZCode (Z.ai GLM
coding agent) audience.
Loading order (later entries win):
1. Built-in defaults
2. Install settings.json (~/.zcode/hooks/hindsight/settings.json)
3. User config (~/.hindsight/zcode.json)
4. Environment variable overrides
`~/.hindsight/zcode.json` is the recommended place to configure the
integration — stable across updates.
"""
import json
import os
import sys
DEFAULTS = {
# Recall
"autoRecall": True,
"recallBudget": "mid",
"recallMaxTokens": 1024,
"recallTimeout": 10,
"recallTypes": ["world", "experience"],
"recallContextTurns": 1,
"recallMaxQueryChars": 800,
"recallRoles": ["user", "assistant"],
"recallPromptPreamble": (
"Relevant memories from past conversations (prioritize recent when "
"conflicting). Only use memories that are directly useful to continue "
"this conversation; ignore the rest:"
),
# Retain (Stop hook pairs each stashed prompt with the assistant reply and
# retains that turn; retainEveryNTurns=1 keeps every turn).
"autoRetain": True,
"retainRoles": ["user", "assistant"],
"retainEveryNTurns": 1,
"retainContext": "zcode",
"retainTags": ["{session_id}"],
"retainMetadata": {},
# Connection
"hindsightApiUrl": None,
"hindsightApiToken": None,
"apiPort": 9077,
"daemonIdleTimeout": 0,
"embedVersion": "latest",
"embedPackagePath": None,
# Bank
"bankId": None,
"bankIdPrefix": "",
"dynamicBankId": False,
"dynamicBankGranularity": ["agent", "project"],
"bankMission": (
"You are a ZCode (Z.ai GLM) coding assistant. Focus on technical "
"decisions, code changes, debugging sessions, and project context "
"relevant to the user's work."
),
"retainMission": (
"Extract technical decisions, code patterns, debugging solutions, user "
"preferences, project context, and architectural choices. Ignore routine "
"greetings and transient operational details."
),
"agentName": "zcode",
# LLM (for daemon mode)
"llmProvider": None,
"llmModel": None,
"llmApiKeyEnv": None,
# Misc
"debug": False,
}
ENV_OVERRIDES = {
"HINDSIGHT_API_URL": ("hindsightApiUrl", str),
"HINDSIGHT_API_TOKEN": ("hindsightApiToken", str),
"HINDSIGHT_BANK_ID": ("bankId", str),
"HINDSIGHT_AGENT_NAME": ("agentName", str),
"HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
"HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
"HINDSIGHT_RECALL_BUDGET": ("recallBudget", str),
"HINDSIGHT_RECALL_MAX_TOKENS": ("recallMaxTokens", int),
"HINDSIGHT_RECALL_TIMEOUT": ("recallTimeout", int),
"HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
"HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
"HINDSIGHT_API_PORT": ("apiPort", int),
"HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),
"HINDSIGHT_EMBED_VERSION": ("embedVersion", str),
"HINDSIGHT_EMBED_PACKAGE_PATH": ("embedPackagePath", str),
"HINDSIGHT_DYNAMIC_BANK_ID": ("dynamicBankId", bool),
"HINDSIGHT_BANK_MISSION": ("bankMission", str),
"HINDSIGHT_LLM_PROVIDER": ("llmProvider", str),
"HINDSIGHT_LLM_MODEL": ("llmModel", str),
"HINDSIGHT_DEBUG": ("debug", bool),
}
def _cast_env(value, typ):
"""Cast environment variable string to target type. Returns None on failure."""
try:
if typ is bool:
return value.lower() in ("true", "1", "yes")
if typ is int:
return int(value)
return value
except (ValueError, AttributeError):
return None
def _load_settings_file(path, config):
"""Merge a settings.json file into config in-place. Silently skips if missing."""
if not os.path.exists(path):
return
try:
with open(path) as f:
file_config = json.load(f)
config.update({k: v for k, v in file_config.items() if v is not None})
except (json.JSONDecodeError, OSError) as e:
debug_log(config, f"Failed to load {path}: {e}")
def load_config():
"""Load plugin configuration from settings.json + env overrides."""
config = dict(DEFAULTS)
# 1. Plugin install settings.json (shipped with the integration, in the
# install root — the parent of the `scripts` and `lib` directories).
install_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_load_settings_file(os.path.join(install_root, "settings.json"), config)
# 2. User config — stable, version-independent
user_config_path = os.path.join(os.path.expanduser("~"), ".hindsight", "zcode.json")
_load_settings_file(user_config_path, config)
# 3. Environment variable overrides (highest priority)
for env_name, (key, typ) in ENV_OVERRIDES.items():
val = os.environ.get(env_name)
if val is not None:
cast_val = _cast_env(val, typ)
if cast_val is not None:
config[key] = cast_val
return config
def debug_log(config, *args):
"""Log to stderr if debug mode is enabled."""
if config.get("debug"):
print("[Hindsight]", *args, file=sys.stderr)
@@ -0,0 +1,574 @@
"""Content processing utilities for the ZCode integration.
Adapts `hindsight-integrations/codex/scripts/lib/content.py` for ZCode's
on-disk transcript format.
ZCode transcript formats (JSONL):
The ephemeral `Stop`-hook transcript (``transcript_path``) contains only the
latest assistant message, one line per message, with NO top-level ``type``:
- {"message": {"role": "assistant", "content": [{"type": "text", "text": "..."}]}}
- {"message": {"role": "assistant", "content": "..."}}
The richer SDK-stream format tags each line with a top-level ``type``:
- {"type": "user", "message": {"role": "user", "content": [TextBlock...]}}
- {"type": "assistant", "message": {"role": "assistant", "content": [TextBlock|ToolUseBlock...]}}
- {"type": "system", ...} (init metadata)
- {"type": "thinking", "text": "..."} (reasoning)
- {"type": "tool_call", "name": "...", "args": ..., "result": ...} (tool lifecycle)
- {"type": "status", "status": "..."} (lifecycle transitions)
- {"type": "task", ...} (task milestones)
- {"type": "request", ...} (awaiting user input)
TextBlock is always `{"type": "text", "text": "..."}`. ToolUseBlock
can vary; the docs warn that tool args/result shape is unstable.
For testing and future compatibility we also accept a flat shape:
- {"role": "user", "content": "..."}
- {"role": "user", "content": [{"type": "text", "text": "..."}]}
"""
import json
import os
import re
from datetime import datetime, timezone
_MAX_TOOL_OUTPUT_CHARS = 2000
# ---------------------------------------------------------------------------
# Memory tag stripping (anti-feedback-loop)
# ---------------------------------------------------------------------------
def strip_memory_tags(content):
"""Remove <hindsight_memories> and <relevant_memories> blocks.
Prevents retain feedback loop — these were injected during recall and
should not be re-stored.
"""
if not isinstance(content, str):
return content
content = re.sub(r"<hindsight_memories>[\s\S]*?</hindsight_memories>", "", content)
content = re.sub(r"<relevant_memories>[\s\S]*?</relevant_memories>", "", content)
return content
# ---------------------------------------------------------------------------
# Transcript reading
# ---------------------------------------------------------------------------
def read_transcript(transcript_path, include_tool_calls=False):
"""Read a ZCode JSONL transcript and return list of message dicts.
When `include_tool_calls` is False (default for retention), we keep
the transcript light: only text from user/assistant messages is
preserved, with the [role:]...[role:end] markers downstream.
When `include_tool_calls` is True, we project tool_call events into
structured content blocks (matching Claude Code's JSON format):
- {"role": "user", "content": [{"type": "text", "text": "..."}]}
- {"role": "assistant", "content": [
{"type": "text", "text": "..."},
{"type": "tool_use", "name": "shell", "input": {...}},
{"type": "tool_result", "content": "..."},
]}
Flat format for testing:
- {"role": "user", "content": "..."}
"""
if not transcript_path or not os.path.isfile(transcript_path):
return []
if include_tool_calls:
return _read_transcript_rich(transcript_path)
return _read_transcript_text(transcript_path)
def _read_transcript_text(transcript_path):
"""Light text-only transcript reader — user/assistant text only."""
messages = []
try:
with open(transcript_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
# Flat test format
if "role" in entry and "content" in entry:
role = entry.get("role")
if role not in ("user", "assistant"):
continue
text = entry["content"]
if isinstance(text, list):
text = _extract_text_from_blocks(text)
if isinstance(text, str) and text.strip():
messages.append({"role": role, "content": text.strip()})
continue
# ZCode SDK envelope (typed) or ephemeral Stop transcript
# (bare {"message": {...}} with no top-level "type").
if entry.get("type") in ("user", "assistant") or (
"type" not in entry and isinstance(entry.get("message"), dict)
):
msg = entry.get("message", {})
role = msg.get("role") or entry.get("type")
if role not in ("user", "assistant"):
continue
text = _extract_text_from_blocks(msg.get("content", []))
if text.strip():
messages.append({"role": role, "content": text.strip()})
except OSError:
pass
return messages
def _read_transcript_rich(transcript_path):
"""Rich transcript reader that preserves tool calls as structured blocks.
Collects all assistant-side events between user messages into a
single assistant message with structured content blocks.
"""
messages = []
assistant_blocks = []
def _flush_assistant():
if assistant_blocks:
# Pass a snapshot — `assistant_blocks` is reused for the next
# turn and we don't want later clears() to wipe this one.
messages.append({"role": "assistant", "content": list(assistant_blocks)})
assistant_blocks.clear()
try:
with open(transcript_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
# Flat test format
if "role" in entry and "content" in entry:
role = entry["role"]
content = entry["content"]
if role == "user":
_flush_assistant()
if isinstance(content, str):
content = [{"type": "text", "text": content}]
messages.append({"role": "user", "content": content})
elif role == "assistant":
if isinstance(content, str):
content = [{"type": "text", "text": content}]
if isinstance(content, list):
assistant_blocks.extend(content)
else:
assistant_blocks.append({"type": "text", "text": str(content)})
continue
event_type = entry.get("type")
# Ephemeral Stop transcript: bare {"message": {...}} with no
# top-level "type". Map role from the nested message.
if event_type is None and isinstance(entry.get("message"), dict):
msg = entry["message"]
role = msg.get("role")
text = _extract_text_from_blocks(msg.get("content", []))
if role == "user":
_flush_assistant()
if text.strip():
messages.append({"role": "user", "content": [{"type": "text", "text": text.strip()}]})
elif role == "assistant" and text.strip():
assistant_blocks.append({"type": "text", "text": text.strip()})
continue
if event_type == "user":
_flush_assistant()
msg = entry.get("message", {})
text = _extract_text_from_blocks(msg.get("content", []))
if text.strip():
messages.append({"role": "user", "content": [{"type": "text", "text": text.strip()}]})
elif event_type == "assistant":
msg = entry.get("message", {})
text = _extract_text_from_blocks(msg.get("content", []))
if text.strip():
assistant_blocks.append({"type": "text", "text": text.strip()})
elif event_type == "thinking":
text = entry.get("text", "")
if text:
assistant_blocks.append({"type": "text", "text": f"[thinking] {text.strip()}"})
elif event_type == "tool_call":
name = entry.get("name", "unknown")
status = entry.get("status", "running")
args = entry.get("args")
result = entry.get("result")
truncated = entry.get("truncated") or {}
if args is not None:
assistant_blocks.append(
{
"type": "tool_use",
"name": name,
"input": _maybe_json_loads(args),
"truncated": bool(truncated.get("args")),
}
)
if status in ("completed", "error") and result is not None:
result_text = _coerce_result_text(result)
assistant_blocks.append(
{
"type": "tool_result",
"name": name,
"content": _truncate(result_text),
"truncated": bool(truncated.get("result")),
"status": status,
}
)
elif event_type == "status":
# Mostly lifecycle telemetry; skip but keep in transcript.
continue
elif event_type == "task":
text = entry.get("text") or entry.get("summary") or ""
if text:
assistant_blocks.append({"type": "text", "text": f"[task] {text.strip()}"})
elif event_type == "request":
# Awaiting user input — nothing to retain.
continue
elif event_type == "system":
# Init metadata.
continue
except OSError:
pass
_flush_assistant()
return messages
def _extract_text_from_blocks(blocks):
"""Extract plain text from a list of ZCode content blocks."""
if isinstance(blocks, str):
return blocks
if not isinstance(blocks, list):
return ""
parts = []
for block in blocks:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
if text:
parts.append(text)
return "\n".join(parts).strip()
def _coerce_result_text(result):
"""Coerce a tool result (unknown shape) into a string."""
if isinstance(result, str):
return result
if isinstance(result, list):
parts = []
for item in result:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
else:
parts.append(json.dumps(item, ensure_ascii=False))
return "\n".join(parts)
if isinstance(result, dict):
return json.dumps(result, ensure_ascii=False)
return str(result)
def _maybe_json_loads(value):
"""If `value` looks like a JSON string, parse it; else return as-is."""
if isinstance(value, str):
s = value.strip()
if s.startswith("{") or s.startswith("["):
try:
return json.loads(s)
except json.JSONDecodeError:
return value
return value
def _truncate(text):
if len(text) > _MAX_TOOL_OUTPUT_CHARS:
return text[:_MAX_TOOL_OUTPUT_CHARS] + "... (truncated)"
return text
# ---------------------------------------------------------------------------
# Recall: query composition and truncation
# ---------------------------------------------------------------------------
def compose_recall_query(latest_query, messages, recall_context_turns, recall_roles=None):
"""Compose a multi-turn recall query from conversation history."""
latest = latest_query.strip()
if recall_context_turns <= 1 or not isinstance(messages, list) or not messages:
return latest
allowed_roles = set(recall_roles or ["user", "assistant"])
contextual_messages = slice_last_turns_by_user_boundary(messages, recall_context_turns)
context_lines = []
for msg in contextual_messages:
role = msg.get("role")
if role not in allowed_roles:
continue
content = msg.get("content", "")
if not isinstance(content, str):
content = str(content)
content = strip_memory_tags(content).strip()
if not content:
continue
if role == "user" and content == latest:
continue
context_lines.append(f"{role}: {content}")
if not context_lines:
return latest
return "\n\n".join(
[
"Prior context:",
"\n".join(context_lines),
latest,
]
)
def truncate_recall_query(query, latest_query, max_chars):
"""Truncate a composed recall query to max_chars. Preserves the latest user message."""
if max_chars <= 0:
return query
latest = latest_query.strip()
if len(query) <= max_chars:
return query
latest_only = latest[:max_chars] if len(latest) > max_chars else latest
if "Prior context:" not in query:
return latest_only
context_marker = "Prior context:\n\n"
marker_index = query.find(context_marker)
if marker_index == -1:
return latest_only
suffix_marker = "\n\n" + latest
suffix_index = query.rfind(suffix_marker)
if suffix_index == -1:
return latest_only
suffix = query[suffix_index:]
if len(suffix) >= max_chars:
return latest_only
context_body = query[marker_index + len(context_marker) : suffix_index]
context_lines = [line for line in context_body.split("\n") if line]
kept = []
for i in range(len(context_lines) - 1, -1, -1):
kept.insert(0, context_lines[i])
candidate = f"{context_marker}{chr(10).join(kept)}{suffix}"
if len(candidate) > max_chars:
kept.pop(0)
break
if kept:
return f"{context_marker}{chr(10).join(kept)}{suffix}"
return latest_only
# ---------------------------------------------------------------------------
# Turn slicing
# ---------------------------------------------------------------------------
def slice_last_turns_by_user_boundary(messages, turns):
"""Slice messages to the last N turns, where a turn starts at a user message."""
if not isinstance(messages, list) or not messages or turns <= 0:
return []
user_turns_seen = 0
start_index = -1
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
user_turns_seen += 1
if user_turns_seen >= turns:
start_index = i
break
if start_index == -1:
return list(messages)
return messages[start_index:]
# ---------------------------------------------------------------------------
# Memory formatting (recall results → context string)
# ---------------------------------------------------------------------------
def format_memories(results):
"""Format recall results into human-readable text."""
if not results:
return ""
lines = []
for r in results:
text = r.get("text", "")
mem_type = r.get("type", "")
mentioned_at = r.get("mentioned_at", "")
type_str = f" [{mem_type}]" if mem_type else ""
date_str = f" ({mentioned_at})" if mentioned_at else ""
lines.append(f"- {text}{type_str}{date_str}")
return "\n\n".join(lines)
def format_current_time():
"""Format current UTC time for recall context."""
now = datetime.now(timezone.utc)
return now.strftime("%Y-%m-%d %H:%M")
# ---------------------------------------------------------------------------
# Retention transcript formatting
# ---------------------------------------------------------------------------
def prepare_retention_transcript(
messages,
retain_roles=None,
retain_full_window=False,
include_tool_calls=False,
):
"""Format messages into a retention transcript.
When `include_tool_calls` is True, output JSON with full message
structure including tool calls and their inputs (matching Claude
Code's format). Otherwise output the legacy text format with
[role: ...]...[role:end] markers.
Returns:
(transcript_text, message_count) or (None, 0) if nothing to retain.
"""
if not messages:
return None, 0
if retain_full_window:
target_messages = messages
else:
last_user_idx = -1
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
last_user_idx = i
break
if last_user_idx == -1:
return None, 0
target_messages = messages[last_user_idx:]
allowed_roles = set(retain_roles or ["user", "assistant"])
if include_tool_calls:
return _prepare_json_transcript(target_messages, allowed_roles)
return _prepare_text_transcript(target_messages, allowed_roles)
def _prepare_json_transcript(messages, allowed_roles):
"""Format messages as JSON with full tool call data."""
structured_messages = []
for msg in messages:
role = msg.get("role", "unknown")
if role not in allowed_roles:
continue
content = msg.get("content", "")
blocks = _strip_memory_tags_from_blocks(content)
if not blocks:
continue
structured_messages.append({"role": role, "content": blocks})
if not structured_messages:
return None, 0
transcript = json.dumps(structured_messages, ensure_ascii=False)
if len(transcript.strip()) < 10:
return None, 0
return transcript, len(structured_messages)
def _prepare_text_transcript(messages, allowed_roles):
"""Format messages as legacy text with [role:]...[role:end] markers."""
parts = []
for msg in messages:
role = msg.get("role", "unknown")
if role not in allowed_roles:
continue
content = msg.get("content", "")
if not isinstance(content, str):
content = str(content)
content = strip_memory_tags(content).strip()
if not content:
continue
parts.append(f"[role: {role}]\n{content}\n[{role}:end]")
if not parts:
return None, 0
transcript = "\n\n".join(parts)
if len(transcript.strip()) < 10:
return None, 0
return transcript, len(parts)
def _strip_memory_tags_from_blocks(content):
"""Strip memory tags from content, handling both string and list formats."""
if isinstance(content, str):
cleaned = strip_memory_tags(content).strip()
return [{"type": "text", "text": cleaned}] if cleaned else []
if not isinstance(content, list):
return []
blocks = []
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type", "")
if block_type == "text":
text = strip_memory_tags(block.get("text", "")).strip()
if text:
blocks.append({"type": "text", "text": text})
elif block_type in ("tool_use", "tool_result"):
# Pass through tool blocks as-is
blocks.append(block)
return blocks
@@ -0,0 +1,275 @@
"""Hindsight-embed daemon lifecycle management.
Mirrors `hindsight-integrations/codex/scripts/lib/daemon.py` with the
profile name rebranded for the ZCode integration. Manages three
connection modes:
1. External API — user provides hindsightApiUrl (skip daemon entirely)
2. Existing local server — user already has hindsight running
3. Auto-managed daemon — integration starts/stops hindsight-embed
Daemon state is tracked via files in ~/.hindsight/zcode/state/.
"""
import os
import platform
import subprocess
import time
import urllib.error
import urllib.request
from .client import USER_AGENT
from .llm import detect_llm_config, get_llm_env_vars
from .state import write_state
DAEMON_STATE_FILE = "daemon.json"
PROFILE_NAME = "zcode"
def _get_embed_command(config):
"""Get the command to run hindsight-embed."""
embed_path = config.get("embedPackagePath")
if embed_path:
return ["uv", "run", "--directory", embed_path, "hindsight-embed"]
version = config.get("embedVersion", "latest")
package = f"hindsight-embed@{version}" if version else "hindsight-embed@latest"
return ["uvx", package]
def _run_embed(config, args, env=None, timeout=10):
"""Run a hindsight-embed command and return the result."""
cmd = _get_embed_command(config) + args
run_env = dict(os.environ)
if env:
run_env.update(env)
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=run_env,
)
def _is_embed_available(config):
"""Quick check if hindsight-embed is available on PATH."""
import shutil
embed_path = config.get("embedPackagePath")
if embed_path:
return os.path.isdir(embed_path)
return shutil.which("uvx") is not None or shutil.which("hindsight-embed") is not None
def _check_health(base_url, timeout=2):
"""Quick health check against a Hindsight server."""
try:
url = f"{base_url.rstrip('/')}/health"
req = urllib.request.Request(url, method="GET", headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status == 200
except Exception:
return False
def get_api_url(config, debug_fn=None, allow_daemon_start=False):
"""Determine the API URL, optionally starting daemon if needed.
Connection mode priority:
1. External API (hindsightApiUrl configured)
2. Existing local server (check port health)
3. Auto-managed daemon (only if allow_daemon_start=True)
"""
external_url = config.get("hindsightApiUrl")
if external_url:
if debug_fn:
debug_fn(f"Using external API: {external_url}")
return external_url
port = config.get("apiPort", 9077)
base_url = f"http://127.0.0.1:{port}"
if _check_health(base_url):
if debug_fn:
debug_fn(f"Existing server healthy on port {port}")
return base_url
if not allow_daemon_start:
raise RuntimeError(
f"No Hindsight server on port {port}. Set hindsightApiUrl for external "
"API, start hindsight-embed manually, or wait for the retain hook to "
"auto-start the daemon."
)
if debug_fn:
debug_fn(f"No server on port {port}, attempting daemon start")
try:
_ensure_daemon_running(config, port, debug_fn)
except Exception as e:
if debug_fn:
debug_fn(f"Daemon start failed: {e}")
raise RuntimeError(
"No Hindsight server available. Set hindsightApiUrl for external API, "
"or ensure hindsight-embed is installed for local daemon mode."
) from e
return base_url
def _ensure_daemon_running(config, port, debug_fn=None):
"""Start the hindsight-embed daemon if not already running."""
if not _is_embed_available(config):
raise RuntimeError(
"hindsight-embed not found (uvx not on PATH). "
"Install with: pip install hindsight-embed, or set hindsightApiUrl."
)
base_url = f"http://127.0.0.1:{port}"
try:
llm_config = detect_llm_config(config)
except RuntimeError as e:
raise RuntimeError(f"Cannot start daemon: {e}") from e
llm_env = get_llm_env_vars(llm_config)
daemon_env = dict(llm_env)
idle_timeout = config.get("daemonIdleTimeout", 0)
daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout)
if platform.system() == "Darwin":
daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
if debug_fn:
debug_fn(f'Configuring "{PROFILE_NAME}" profile...')
profile_args = [
"profile",
"create",
PROFILE_NAME,
"--merge",
"--port",
str(port),
]
for env_name, env_val in daemon_env.items():
if env_val:
profile_args.extend(["--env", f"{env_name}={env_val}"])
try:
result = _run_embed(config, profile_args, daemon_env, timeout=10)
if result.returncode != 0:
if debug_fn:
debug_fn(f"Profile create stderr: {result.stderr.strip()}")
raise RuntimeError(f"Profile create failed (exit {result.returncode}): {result.stderr}")
if debug_fn:
debug_fn("Profile configured")
except subprocess.TimeoutExpired:
raise RuntimeError("Profile create timed out")
except FileNotFoundError:
raise RuntimeError(
"hindsight-embed not found. Install with: pip install hindsight-embed "
"or set hindsightApiUrl for external API mode."
)
if debug_fn:
debug_fn("Starting daemon...")
try:
result = _run_embed(
config,
["daemon", "--profile", PROFILE_NAME, "start"],
daemon_env,
timeout=30,
)
if debug_fn:
debug_fn(f"Daemon start exit={result.returncode} stdout={result.stdout.strip()}")
if result.returncode != 0 and "already running" not in result.stderr.lower():
raise RuntimeError(f"Daemon start failed (exit {result.returncode}): {result.stderr}")
except subprocess.TimeoutExpired:
raise RuntimeError("Daemon start timed out")
if debug_fn:
debug_fn("Waiting for daemon to be ready...")
for attempt in range(30):
if _check_health(base_url):
if debug_fn:
debug_fn(f"Daemon ready after {attempt + 1} attempts")
write_state(
DAEMON_STATE_FILE,
{
"port": port,
"started_by_plugin": True,
"started_at": time.time(),
"pid": os.getpid(),
},
)
return
time.sleep(1)
raise RuntimeError("Daemon failed to become ready within 30 seconds")
def prestart_daemon_background(config, debug_fn=None):
"""Fire off daemon startup in the background — non-blocking.
Called from sessionStart to warm up the daemon before the first
recall or retain hook fires.
"""
if config.get("hindsightApiUrl"):
return
port = config.get("apiPort", 9077)
if _check_health(f"http://127.0.0.1:{port}"):
if debug_fn:
debug_fn(f"Daemon already running on port {port}, skipping pre-start")
return
if not _is_embed_available(config):
if debug_fn:
debug_fn("hindsight-embed not available, skipping pre-start")
return
try:
llm_config = detect_llm_config(config)
except RuntimeError as e:
if debug_fn:
debug_fn(f"No LLM configured, skipping daemon pre-start: {e}")
return
llm_env = get_llm_env_vars(llm_config)
daemon_env = dict(os.environ)
daemon_env.update(llm_env)
idle_timeout = config.get("daemonIdleTimeout", 0)
daemon_env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout)
if platform.system() == "Darwin":
daemon_env["HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"] = "1"
daemon_env["HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"] = "1"
embed_cmd = _get_embed_command(config)
profile_args = ["profile", "create", PROFILE_NAME, "--merge", "--port", str(port)]
for env_name, env_val in llm_env.items():
if env_val:
profile_args.extend(["--env", f"{env_name}={env_val}"])
import shlex
profile_str = shlex.join(embed_cmd + profile_args)
daemon_str = shlex.join(embed_cmd + ["daemon", "--profile", PROFILE_NAME, "start"])
import subprocess as _sp
_sp.Popen(
f"{profile_str} && {daemon_str}",
shell=True,
env=daemon_env,
stdout=_sp.DEVNULL,
stderr=_sp.DEVNULL,
start_new_session=True,
)
if debug_fn:
debug_fn(f"Daemon pre-start initiated in background (port {port})")
@@ -0,0 +1,141 @@
"""LLM provider detection for Hindsight's fact extraction.
Port of `hindsight-integrations/codex/scripts/lib/llm.py`. Used by the
daemon lifecycle so the local `hindsight-embed` subprocess knows which LLM
to use for fact extraction.
When running hindsight-embed locally (daemon mode), it needs an LLM to
extract facts from retained conversations. Detection priority:
1. HINDSIGHT_API_LLM_* environment variables (highest priority)
2. Plugin config (llmProvider, llmModel, llmApiKeyEnv)
3. Auto-detect from standard provider env vars
4. External API mode (server-side LLM, no local config needed)
"""
import os
PROVIDER_DETECTION = [
{"name": "openai", "key_env": "OPENAI_API_KEY"},
{"name": "anthropic", "key_env": "ANTHROPIC_API_KEY"},
{"name": "gemini", "key_env": "GEMINI_API_KEY"},
{"name": "groq", "key_env": "GROQ_API_KEY"},
{"name": "ollama", "key_env": ""},
{"name": "openai-codex", "key_env": ""},
{"name": "claude-code", "key_env": ""},
]
NO_KEY_REQUIRED = {"ollama", "openai-codex", "claude-code"}
def _find_provider(name):
for p in PROVIDER_DETECTION:
if p["name"] == name:
return p
return None
def detect_llm_config(config):
"""Detect LLM configuration.
Returns dict with: provider, api_key, model, base_url, source.
Returns None values for external API mode (server handles LLM).
Raises RuntimeError if no configuration found and not in external API mode.
"""
override_provider = os.environ.get("HINDSIGHT_API_LLM_PROVIDER")
override_model = os.environ.get("HINDSIGHT_API_LLM_MODEL")
override_key = os.environ.get("HINDSIGHT_API_LLM_API_KEY")
override_base_url = os.environ.get("HINDSIGHT_API_LLM_BASE_URL")
# Priority 1: HINDSIGHT_API_LLM_PROVIDER env var
if override_provider:
if not override_key and override_provider not in NO_KEY_REQUIRED:
raise RuntimeError(
f'HINDSIGHT_API_LLM_PROVIDER is set to "{override_provider}" but HINDSIGHT_API_LLM_API_KEY is not set.'
)
pinfo = _find_provider(override_provider)
return {
"provider": override_provider,
"api_key": override_key or "",
"model": override_model,
"base_url": override_base_url,
"source": "HINDSIGHT_API_LLM_PROVIDER override",
}
# Priority 2: Plugin config llmProvider/llmModel
cfg_provider = config.get("llmProvider")
if cfg_provider:
pinfo = _find_provider(cfg_provider)
api_key = ""
key_env_name = config.get("llmApiKeyEnv")
if key_env_name:
api_key = os.environ.get(key_env_name, "")
elif pinfo and pinfo["key_env"]:
api_key = os.environ.get(pinfo["key_env"], "")
if not api_key and cfg_provider not in NO_KEY_REQUIRED:
key_source = key_env_name or (pinfo["key_env"] if pinfo else "unknown")
raise RuntimeError(
f'Plugin config llmProvider is "{cfg_provider}" but no API key found. Expected env var: {key_source}'
)
return {
"provider": cfg_provider,
"api_key": api_key,
"model": config.get("llmModel") or override_model,
"base_url": override_base_url,
"source": "plugin config",
}
# Priority 3: Auto-detect from standard provider env vars
for pinfo in PROVIDER_DETECTION:
if pinfo["name"] in NO_KEY_REQUIRED:
continue
if not pinfo["key_env"]:
continue
api_key = os.environ.get(pinfo["key_env"], "")
if api_key:
return {
"provider": pinfo["name"],
"api_key": api_key,
"model": override_model,
"base_url": override_base_url,
"source": f"auto-detected from {pinfo['key_env']}",
}
# Priority 4: External API mode — server handles LLM
if config.get("hindsightApiUrl"):
return {
"provider": None,
"api_key": None,
"model": None,
"base_url": None,
"source": "external-api-mode-no-llm",
}
raise RuntimeError(
"No LLM configuration found for Hindsight.\n\n"
"Option 1: Set a standard provider API key (auto-detect):\n"
" export OPENAI_API_KEY=sk-your-key\n"
" export ANTHROPIC_API_KEY=your-key\n\n"
"Option 2: Override with Hindsight-specific env vars:\n"
" export HINDSIGHT_API_LLM_PROVIDER=openai\n"
" export HINDSIGHT_API_LLM_API_KEY=sk-your-key\n\n"
"Option 3: Use an external Hindsight API (server-side LLM):\n"
" Set hindsightApiUrl in settings.json or HINDSIGHT_API_URL env var\n\n"
"The model will be selected automatically by Hindsight. "
"To override: export HINDSIGHT_API_LLM_MODEL=your-model"
)
def get_llm_env_vars(llm_config):
"""Build environment variables for the hindsight-embed daemon."""
env = {}
if llm_config.get("provider"):
env["HINDSIGHT_API_LLM_PROVIDER"] = llm_config["provider"]
if llm_config.get("api_key"):
env["HINDSIGHT_API_LLM_API_KEY"] = llm_config["api_key"]
if llm_config.get("model"):
env["HINDSIGHT_API_LLM_MODEL"] = llm_config["model"]
if llm_config.get("base_url"):
env["HINDSIGHT_API_LLM_BASE_URL"] = llm_config["base_url"]
return env
@@ -0,0 +1,110 @@
"""File-based state persistence.
ZCode hooks are ephemeral processes — state must be persisted to files.
Uses ~/.hindsight/zcode/state/ as the storage directory.
"""
import json
import os
import re
import sys
if sys.platform != "win32":
import fcntl
else:
fcntl = None
def _state_dir():
"""Get the state directory, creating it if needed."""
state_dir = os.path.join(os.path.expanduser("~"), ".hindsight", "zcode", "state")
os.makedirs(state_dir, exist_ok=True)
return state_dir
def _safe_filename(name):
"""Sanitize a filename to prevent path traversal."""
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", name)
name = name.replace("..", "_")
name = name[:200]
return name or "state"
def _state_file(name):
"""Get path for a state file. Name is sanitized to prevent traversal."""
safe = _safe_filename(name)
path = os.path.join(_state_dir(), safe)
resolved = os.path.realpath(path)
expected_dir = os.path.realpath(_state_dir())
if not resolved.startswith(expected_dir + os.sep) and resolved != expected_dir:
raise ValueError(f"State file path escapes state directory: {name!r}")
return path
def read_state(name, default=None):
"""Read a JSON state file. Returns default if not found."""
path = _state_file(name)
if not os.path.exists(path):
return default
try:
with open(path) as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return default
def write_state(name, data):
"""Write data to a JSON state file atomically."""
path = _state_file(name)
tmp_path = path + ".tmp"
try:
with open(tmp_path, "w") as f:
json.dump(data, f)
os.replace(tmp_path, path)
except OSError:
try:
os.unlink(tmp_path)
except OSError:
pass
def get_turn_count(session_id):
"""Get the current turn count for a session."""
turns = read_state("turns.json", {})
return turns.get(session_id, 0)
def increment_turn_count(session_id):
"""Increment and return the turn count for a session.
Uses flock on Unix to prevent race conditions. On Windows, proceeds
without a lock — minor races here are harmless.
"""
lock_path = _state_file("turns.lock")
if fcntl is not None:
try:
lock_fd = open(lock_path, "w")
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
turns = read_state("turns.json", {})
turns[session_id] = turns.get(session_id, 0) + 1
if len(turns) > 10000:
sorted_keys = sorted(turns.keys())
for k in sorted_keys[: len(sorted_keys) // 2]:
del turns[k]
write_state("turns.json", turns)
return turns[session_id]
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
except OSError:
pass
turns = read_state("turns.json", {})
turns[session_id] = turns.get(session_id, 0) + 1
if len(turns) > 10000:
sorted_keys = sorted(turns.keys())
for k in sorted_keys[: len(sorted_keys) // 2]:
del turns[k]
write_state("turns.json", turns)
return turns[session_id]
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Auto-recall hook for ZCode's `UserPromptSubmit` event.
This hook reads the prompt from stdin as JSON and emits additional context via
the `hookSpecificOutput.additionalContext` field.
Fires right after the user hits send but before the backend request.
Retrieves relevant memories from Hindsight and injects them so the agent has
continuity across sessions. It also stashes the user prompt to state so the
`Stop` hook (retain) can pair it with the assistant's reply — ZCode's ephemeral
Stop transcript is assistant-only, so retain relies on this stash to reconstruct
the full turn.
Flow:
1. Read hook input from stdin (prompt, session_id/sessionId, transcript_path, cwd, ...)
2. Stash the prompt to state (last_prompt_<session_id>.json) for retain to pair
3. Resolve API URL
4. Derive bank ID and ensure mission
5. Compose multi-turn query if recallContextTurns > 1
6. Truncate to recallMaxQueryChars
7. Call Hindsight recall API
8. Format memories and emit the `UserPromptSubmit` output:
{ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit",
"additionalContext": "<hindsight_memories>..." } }
Exit codes:
0 — always (graceful degradation on any error).
"""
import io
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.bank import derive_bank_id, ensure_bank_mission
from lib.client import HindsightClient
from lib.config import debug_log, load_config
from lib.content import (
compose_recall_query,
format_current_time,
format_memories,
read_transcript,
truncate_recall_query,
)
from lib.daemon import get_api_url
from lib.state import write_state
LAST_RECALL_STATE = "last_recall.json"
def main():
if sys.platform == "win32":
sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
config = load_config()
if not config.get("autoRecall"):
debug_log(config, "Auto-recall disabled, exiting")
return
# Read hook input from stdin
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
print("[Hindsight] Failed to read hook input", file=sys.stderr)
return
debug_log(config, f"Hook input keys: {list(hook_input.keys())}")
# Extract user query — accept both "prompt" and "user_prompt" defensively
prompt = (hook_input.get("prompt") or hook_input.get("user_prompt") or "").strip()
# Stash the prompt so the Stop hook (retain) can pair it with the assistant
# reply. ZCode sends both "session_id" and "sessionId"; accept either.
session_id = hook_input.get("session_id") or hook_input.get("sessionId") or "unknown"
if prompt:
write_state(
f"last_prompt_{session_id}.json",
{"prompt": prompt, "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())},
)
if not prompt or len(prompt) < 5:
debug_log(config, "Prompt too short for recall, skipping")
return
def _dbg(*a):
debug_log(config, *a)
try:
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
except RuntimeError as e:
print(f"[Hindsight] {e}", file=sys.stderr)
return
api_token = config.get("hindsightApiToken")
try:
client = HindsightClient(api_url, api_token)
except ValueError as e:
print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
return
bank_id = derive_bank_id(hook_input, config)
ensure_bank_mission(client, bank_id, config, debug_fn=_dbg)
# Multi-turn query composition
recall_context_turns = config.get("recallContextTurns", 1)
recall_max_query_chars = config.get("recallMaxQueryChars", 800)
recall_roles = config.get("recallRoles", ["user", "assistant"])
if recall_context_turns > 1:
transcript_path = hook_input.get("transcript_path", "")
messages = read_transcript(transcript_path)
debug_log(config, f"Multi-turn context: {recall_context_turns} turns, {len(messages)} messages")
query = compose_recall_query(prompt, messages, recall_context_turns, recall_roles)
else:
query = prompt
query = truncate_recall_query(query, prompt, recall_max_query_chars)
if len(query) > recall_max_query_chars:
query = query[:recall_max_query_chars]
query = query.encode("utf-8", errors="ignore").decode("utf-8")
current_time = format_current_time()
preamble = config.get("recallPromptPreamble", "")
recall_timeout = config.get("recallTimeout", 10)
debug_log(config, f"Recalling from bank '{bank_id}', query length: {len(query)}, timeout: {recall_timeout}")
try:
response = client.recall(
bank_id=bank_id,
query=query,
max_tokens=config.get("recallMaxTokens", 1024),
budget=config.get("recallBudget", "mid"),
types=config.get("recallTypes"),
timeout=recall_timeout,
)
except Exception as e:
print(f"[Hindsight] Recall failed: {e}", file=sys.stderr)
return
results = response.get("results", [])
if not results:
debug_log(config, "No memories found")
return
debug_log(config, f"Injecting {len(results)} memories")
memories_formatted = format_memories(results)
context_message = (
f"<hindsight_memories>\n"
f"{preamble}\n"
f"Current time - {current_time}\n\n"
f"{memories_formatted}\n"
f"</hindsight_memories>"
)
write_state(
LAST_RECALL_STATE,
{
"context": context_message,
"saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"bank_id": bank_id,
"result_count": len(results),
},
)
# Claude Code UserPromptSubmit output schema (ZCode embeds the Claude Code
# runtime): additionalContext is appended to the prompt the model sees.
output = {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context_message,
}
}
json.dump(output, sys.stdout)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] Unexpected error in recall: {e}", file=sys.stderr)
try:
from lib.config import load_config
sys.exit(2 if load_config().get("debug") else 0)
except Exception:
sys.exit(0)
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Auto-retain hook for ZCode's `Stop` event.
Fires after the agent loop ends. ZCode's Stop payload carries the full assistant
reply in `responseText` plus an ephemeral `transcript_path` (an assistant-only
temp file that is deleted right after the hook). It does NOT carry the user
prompt, so we pair the assistant reply with the prompt the recall hook stashed
under `last_prompt_<session_id>.json` and retain that turn.
ZCode runs hooks inline (no async), so retain runs synchronously. We keep the
timeout tight and degrade gracefully — if retain fails we log to stderr and
exit 0 so the agent is never blocked.
Assistant text resolution, in order:
1. hook_input["responseText"] (full reply — preferred)
2. transcript_path (parse ZCode {"message": {...}} lines)
3. hook_input["responsePreview"] (truncated fallback)
Flow:
1. Read hook input from stdin (session_id/sessionId, responseText, transcript_path, ...)
2. Resolve assistant text + stashed user prompt
3. Build a [user, assistant] messages list for the turn
4. Apply retainEveryNTurns gating
5. Resolve API URL (external, existing local, or auto-start daemon)
6. Derive bank ID and ensure mission
7. Format transcript (strip memory tags, filter roles)
8. POST to Hindsight retain API (distinct document_id per turn)
Exit codes:
0 — always (graceful degradation on any error)
"""
import json
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.bank import derive_bank_id, ensure_bank_mission
from lib.client import HindsightClient
from lib.config import debug_log, load_config
from lib.content import prepare_retention_transcript, read_transcript
from lib.daemon import get_api_url
from lib.state import increment_turn_count, read_state
def _resolve_assistant_text(hook_input: dict) -> str:
"""Resolve the assistant reply for this turn.
Prefer `responseText`; fall back to parsing the ephemeral transcript for the
last assistant message; final fallback is `responsePreview`.
"""
response_text = (hook_input.get("responseText") or "").strip()
if response_text:
return response_text
transcript_path = hook_input.get("transcript_path", "")
messages = read_transcript(transcript_path, include_tool_calls=False)
for msg in reversed(messages):
if msg.get("role") == "assistant":
content = msg.get("content", "")
if isinstance(content, str) and content.strip():
return content.strip()
return (hook_input.get("responsePreview") or "").strip()
def run_retain(hook_input: dict) -> None:
config = load_config()
if not config.get("autoRetain"):
debug_log(config, "Auto-retain disabled, exiting")
return
debug_log(config, f"Retain hook input keys: {list(hook_input.keys())}")
# ZCode sends both "session_id" and "sessionId"; accept either, plus
# conversation_id for alternative payload shapes.
session_id = (
hook_input.get("session_id") or hook_input.get("sessionId") or hook_input.get("conversation_id") or "unknown"
)
# Assemble the turn from reliable payload fields: the recall hook stashed the
# user prompt, and the Stop payload carries the assistant reply.
assistant_text = _resolve_assistant_text(hook_input)
stashed = read_state(f"last_prompt_{session_id}.json", {}) or {}
prompt = (stashed.get("prompt") or "").strip()
if not assistant_text and not prompt:
debug_log(config, "No assistant text and no stashed prompt, skipping retain")
return
messages_to_retain = []
if prompt:
messages_to_retain.append({"role": "user", "content": prompt})
if assistant_text:
messages_to_retain.append({"role": "assistant", "content": assistant_text})
debug_log(config, f"Assembled turn: {len(messages_to_retain)} messages (prompt={bool(prompt)})")
retain_every_n = max(1, config.get("retainEveryNTurns", 1))
# Gate retain frequency: only every Nth turn is stored when configured.
if retain_every_n > 1:
turn_count = increment_turn_count(session_id)
if turn_count % retain_every_n != 0:
next_at = ((turn_count // retain_every_n) + 1) * retain_every_n
debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
return
# Format transcript. Turns are plain text (no tool-call structure), so the
# text transcript path is used.
retain_roles = config.get("retainRoles", ["user", "assistant"])
transcript, message_count = prepare_retention_transcript(
messages_to_retain, retain_roles, retain_full_window=True, include_tool_calls=False
)
if not transcript:
debug_log(config, "Empty transcript after formatting, skipping retain")
return
# Resolve API URL
def _dbg(*a):
debug_log(config, *a)
try:
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=True)
except RuntimeError as e:
print(f"[Hindsight] {e}", file=sys.stderr)
return
api_token = config.get("hindsightApiToken")
try:
client = HindsightClient(api_url, api_token)
except ValueError as e:
print(f"[Hindsight] Invalid API URL: {e}", file=sys.stderr)
return
# Derive bank ID and ensure mission
bank_id = derive_bank_id(hook_input, config)
ensure_bank_mission(client, bank_id, config, debug_fn=_dbg)
# Distinct document_id per turn so each turn is its own memory and prior
# turns are never overwritten.
document_id = f"{session_id}-{int(time.time() * 1000)}"
# Resolve template variables in tags and metadata
template_vars = {
"session_id": session_id,
"conversation_id": session_id,
"bank_id": bank_id,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
def _resolve_template(value: str) -> str:
for k, v in template_vars.items():
value = value.replace(f"{{{k}}}", v)
return value
raw_tags = config.get("retainTags", [])
tags = [_resolve_template(t) for t in raw_tags] if raw_tags else None
metadata = {
"retained_at": template_vars["timestamp"],
"message_count": str(message_count),
"session_id": session_id,
}
for k, v in config.get("retainMetadata", {}).items():
metadata[k] = _resolve_template(str(v))
debug_log(
config, f"Retaining to bank '{bank_id}', doc '{document_id}', {message_count} messages, {len(transcript)} chars"
)
if tags:
debug_log(config, f"Tags: {tags}")
# POST to Hindsight retain API
try:
response = client.retain(
bank_id=bank_id,
content=transcript,
document_id=document_id,
context=config.get("retainContext", "zcode"),
metadata=metadata,
tags=tags,
timeout=15,
)
debug_log(config, f"Retain response: {json.dumps(response)[:200]}")
except Exception as e:
print(f"[Hindsight] Retain failed: {e}", file=sys.stderr)
def main():
# Read hook input from stdin
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
print("[Hindsight] Failed to read hook input", file=sys.stderr)
return
run_retain(hook_input)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] Unexpected error in retain: {e}", file=sys.stderr)
try:
from lib.config import load_config
sys.exit(2 if load_config().get("debug") else 0)
except Exception:
sys.exit(0)
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""SessionStart hook for ZCode.
ZCode embeds the Claude Code agent runtime, so this hook speaks the standard
Claude Code `SessionStart` protocol. Fires once when a ZCode session begins:
verifies the Hindsight server is reachable, and kicks off a background daemon
pre-start if not — so it's ready by the first recall or retain hook.
`SessionStart` is fire-and-forget here. The full recall happens on
`UserPromptSubmit`.
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.client import HindsightClient
from lib.config import debug_log, load_config
from lib.daemon import get_api_url, prestart_daemon_background
def main():
config = load_config()
if not config.get("autoRecall") and not config.get("autoRetain"):
debug_log(config, "Both autoRecall and autoRetain disabled, skipping session start")
return
# Consume stdin
try:
hook_input = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
hook_input = {}
debug_log(
config,
f"SessionStart hook, session: {hook_input.get('session_id', 'unknown')}",
)
def _dbg(*a):
debug_log(config, *a)
try:
api_url = get_api_url(config, debug_fn=_dbg, allow_daemon_start=False)
HindsightClient(api_url, config.get("hindsightApiToken"))
debug_log(config, f"Hindsight server reachable at {api_url}")
except (RuntimeError, ValueError) as e:
debug_log(config, f"Hindsight not running, initiating background pre-start: {e}")
prestart_daemon_background(config, debug_fn=_dbg)
return
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[Hindsight] SessionStart error: {e}", file=sys.stderr)
sys.exit(0)
@@ -0,0 +1,34 @@
{
"hindsightApiUrl": "",
"bankId": "zcode",
"bankMission": "You are a ZCode (Z.ai GLM) desktop coding assistant. Focus on technical decisions, code changes, debugging sessions, and project context relevant to the user's work.",
"retainMission": "Extract technical decisions, code patterns, debugging solutions, user preferences, project context, and architectural choices. Ignore routine greetings and transient operational details.",
"autoRecall": true,
"autoRetain": true,
"recallBudget": "mid",
"recallMaxTokens": 1024,
"recallTimeout": 10,
"recallTypes": ["world", "experience"],
"recallContextTurns": 1,
"recallMaxQueryChars": 800,
"recallRoles": ["user", "assistant"],
"recallPromptPreamble": "Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:",
"retainRoles": ["user", "assistant"],
"retainEveryNTurns": 1,
"retainTags": ["{session_id}"],
"retainMetadata": {},
"retainContext": "zcode",
"hindsightApiToken": null,
"apiPort": 9077,
"daemonIdleTimeout": 0,
"embedVersion": "latest",
"embedPackagePath": null,
"bankIdPrefix": "",
"dynamicBankId": false,
"dynamicBankGranularity": ["agent", "project"],
"agentName": "zcode",
"llmProvider": null,
"llmModel": null,
"llmApiKeyEnv": null,
"debug": false
}
@@ -0,0 +1,247 @@
"""Install logic for the ZCode Hindsight integration.
ZCode (Z.ai's GLM desktop coding agent, zcode.z.ai) reads configuration hooks
from its CLI config file at ``~/.zcode/cli/config.json`` — never the user's real
Claude Code config at ``~/.claude/settings.json``.
Reproduces the installed layout the hook scripts expect at runtime:
~/.zcode/hooks/hindsight/
scripts/ — the hook scripts + their ``lib/`` package
settings.json — default config (version stamped at install time)
hooks.json — rendered with absolute script paths (reference copy)
~/.zcode/cli/config.json — ZCode CLI config; Hindsight's "hooks" block merged
in, preserving any other keys/hooks already present
~/.hindsight/zcode.json — user config (seeded empty, never overwritten)
ZCode's native hook schema lives under the top-level ``"hooks"`` key:
{"hooks": {"enabled": true, "maxOutputBytes": 32768,
"events": {"<Event>": [{"hooks": [{"type": "process",
"command": "python3",
"args": ["/abs/script.py"],
"timeoutMs": 12000}]}]}}}
Config hooks are disabled by default, so ``hooks.enabled`` is set to true. Only
SessionStart, UserPromptSubmit, and Stop are wired (ZCode has no SessionEnd) —
retain rides the Stop event.
The hook payload (``scripts/``, ``settings.json``, ``hooks.json``) ships as
package data under ``hindsight_zcode/hooks`` and is read via
``importlib.resources`` so it resolves whether installed as a wheel or run from
a source checkout.
"""
import json
import shutil
import sys
from importlib import metadata, resources
from importlib.resources.abc import Traversable
from pathlib import Path
PACKAGE = "hindsight_zcode"
HOOKS_DIRNAME = "hindsight"
SCRIPTS_PLACEHOLDER = "__SCRIPTS_DIR__"
# ZCode caps hook stdout at maxOutputBytes; anything larger is dropped.
MAX_OUTPUT_BYTES = 32768
# Marker used to identify Hindsight's own hook entries when merging/stripping
# the shared ~/.zcode/cli/config.json. Every script path contains this segment
# (the deploy dir is ~/.zcode/hooks/hindsight), so it never matches a foreign
# hook.
HOOK_MARKER = "hooks/hindsight"
def _payload_root() -> Traversable:
"""The packaged hook payload (``scripts/``, ``settings.json``, ``hooks.json``)."""
return resources.files(PACKAGE).joinpath("hooks")
def _package_version() -> str:
"""Installed package version, stamped into the deployed settings.json."""
try:
return metadata.version(PACKAGE)
except metadata.PackageNotFoundError:
return "0.0.0"
def get_zcode_dir() -> Path:
return Path.home() / ".zcode"
def get_config_path() -> Path:
"""The ZCode CLI config that carries the hooks block (``~/.zcode/cli/config.json``)."""
return get_zcode_dir() / "cli" / "config.json"
def get_install_dir() -> Path:
"""Where the hook payload is deployed (``~/.zcode/hooks/hindsight``)."""
return get_zcode_dir() / "hooks" / HOOKS_DIRNAME
def _copy_scripts(install_dir: Path) -> Path:
"""Copy the packaged ``scripts/`` tree into the install dir. Returns its path."""
scripts_dst = install_dir / "scripts"
if scripts_dst.exists():
shutil.rmtree(scripts_dst)
scripts_dst.mkdir(parents=True, exist_ok=True)
# importlib.resources.as_file materialises the packaged dir on disk (a no-op
# copy for a real filesystem install, a real extraction for a zipped wheel).
with resources.as_file(_payload_root().joinpath("scripts")) as scripts_src:
for item in Path(scripts_src).iterdir():
dest = scripts_dst / item.name
if item.is_dir():
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
return scripts_dst
def write_settings(install_dir: Path) -> None:
"""Write the default settings.json, stamping the installed package version."""
settings = json.loads(_payload_root().joinpath("settings.json").read_text())
settings = {"version": _package_version(), **settings}
(install_dir / "settings.json").write_text(json.dumps(settings, indent=2) + "\n")
def render_hooks_events(scripts_dir: Path) -> dict:
"""Load the packaged hooks.json template, substituting the scripts path.
Returns ZCode's native ``events`` map:
{"<EventName>": [ {"hooks": [ {"type": "process",
"command": "python3",
"args": ["/abs/script.py"],
"timeoutMs": 12000} ]} ]}
"""
template = _payload_root().joinpath("hooks.json").read_text()
rendered = template.replace(SCRIPTS_PLACEHOLDER, str(scripts_dir))
return json.loads(rendered)
def _is_hindsight_entry(definition: dict) -> bool:
return HOOK_MARKER in json.dumps(definition)
def merge_hooks(config_path: Path, events: dict) -> Path:
"""Merge Hindsight's hooks into ``~/.zcode/cli/config.json``, preserving others.
ZCode reads its native hook schema under the top-level ``"hooks"`` key:
``{"enabled": true, "maxOutputBytes": N, "events": {...}}``. Config hooks are
disabled by default, so ``enabled`` is forced true. Any other keys in
config.json — and any non-Hindsight event entries — are preserved untouched.
Idempotent: any pre-existing Hindsight entries are replaced, not duplicated.
Returns the path to the config file.
"""
existing: dict = {}
if config_path.exists():
try:
existing = json.loads(config_path.read_text())
except (OSError, ValueError):
existing = {}
hooks = existing.get("hooks")
if not isinstance(hooks, dict):
hooks = {}
hooks["enabled"] = True
hooks.setdefault("maxOutputBytes", MAX_OUTPUT_BYTES)
existing_events = hooks.get("events")
if not isinstance(existing_events, dict):
existing_events = {}
for event, definitions in events.items():
bucket = [d for d in existing_events.get(event, []) if not _is_hindsight_entry(d)]
bucket.extend(definitions)
existing_events[event] = bucket
hooks["events"] = existing_events
existing["hooks"] = hooks
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(existing, indent=2) + "\n")
return config_path
def seed_user_config(api_url: str | None, api_token: str | None) -> Path:
"""Seed ``~/.hindsight/zcode.json`` if absent. Never overwrites."""
user_config = Path.home() / ".hindsight" / "zcode.json"
if user_config.exists():
print(f"User config already exists at {user_config} — leaving it alone")
return user_config
user_config.parent.mkdir(parents=True, exist_ok=True)
user_config.write_text(
json.dumps(
{"hindsightApiUrl": api_url or "", "hindsightApiToken": api_token},
indent=2,
)
+ "\n"
)
print(f"Seeded user config: {user_config}")
return user_config
def run_install(api_url: str | None = None, api_token: str | None = None) -> None:
"""Install the hook scripts and register them with ZCode."""
install_dir = get_install_dir()
print("Installing Hindsight memory for ZCode...")
print(f" Install dir : {install_dir}")
if api_url:
print(f" API URL : {api_url}")
print()
install_dir.mkdir(parents=True, exist_ok=True)
scripts_dir = _copy_scripts(install_dir)
write_settings(install_dir)
events = render_hooks_events(scripts_dir)
# Keep a rendered copy beside the scripts for reference / debugging.
(install_dir / "hooks.json").write_text(json.dumps(events, indent=2) + "\n")
config_path = merge_hooks(get_config_path(), events)
print(f"Hooks registered: {config_path}")
seed_user_config(api_url, api_token)
print()
print("Done. Restart ZCode to load the new hooks.")
print("Logs (with debug=true): tail -F ~/.hindsight/zcode/state/*.log")
def run_uninstall() -> None:
"""Remove the hook scripts and strip Hindsight's entries from ZCode config."""
install_dir = get_install_dir()
config_json = get_config_path()
if install_dir.exists():
shutil.rmtree(install_dir)
print(f"Removed {install_dir}")
else:
print(f"{install_dir} does not exist — nothing to remove")
if config_json.exists():
try:
data = json.loads(config_json.read_text())
except (OSError, ValueError):
data = None
if isinstance(data, dict) and isinstance(data.get("hooks"), dict):
hooks = data["hooks"]
events = hooks.get("events")
if isinstance(events, dict):
for event, definitions in list(events.items()):
kept = [d for d in definitions if not _is_hindsight_entry(d)]
if kept:
events[event] = kept
else:
del events[event]
hooks["events"] = events
config_json.write_text(json.dumps(data, indent=2) + "\n")
print(f"Stripped Hindsight entries from {config_json}")
else:
print(f"{config_json} does not exist — nothing to strip")
print()
print("Done. Restart ZCode to unload the hooks.")
print("User config at ~/.hindsight/zcode.json was preserved.")
if __name__ == "__main__":
sys.exit(run_install())
@@ -0,0 +1,37 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hindsight_zcode/hooks/scripts/session_start.py\" || python \"${CLAUDE_PLUGIN_ROOT}/hindsight_zcode/hooks/scripts/session_start.py\"",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hindsight_zcode/hooks/scripts/recall.py\" || python \"${CLAUDE_PLUGIN_ROOT}/hindsight_zcode/hooks/scripts/recall.py\"",
"timeout": 12
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hindsight_zcode/hooks/scripts/retain.py\" || python \"${CLAUDE_PLUGIN_ROOT}/hindsight_zcode/hooks/scripts/retain.py\"",
"timeout": 15
}
]
}
]
}
}
@@ -0,0 +1,68 @@
[project]
name = "hindsight-zcode"
version = "0.1.0"
description = "ZCode (Z.ai GLM) integration for Hindsight - persistent long-term memory via ZCode hooks"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [
{ name = "Vectorize", email = "[email protected]" }
]
keywords = [
"ai",
"memory",
"zcode",
"glm",
"z.ai",
"hooks",
"agents",
"hindsight",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = []
[project.scripts]
hindsight-zcode = "hindsight_zcode.cli:main"
[project.urls]
Homepage = "https://github.com/vectorize-io/hindsight"
Documentation = "https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/zcode"
Repository = "https://github.com/vectorize-io/hindsight"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_zcode"]
# The hook payload under hindsight_zcode/hooks/ (scripts + JSON) ships as
# package data; the installer extracts it via importlib.resources.
artifacts = [
"hindsight_zcode/hooks/**/*",
]
[tool.hatch.build.targets.sdist]
include = [
"hindsight_zcode/**/*",
"tests/**/*",
"README.md",
"LICENSE",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
[dependency-groups]
dev = [
"pytest>=9.0.2",
"ruff>=0.8.0",
]
@@ -0,0 +1,127 @@
"""Shared fixtures for the Hindsight ZCode integration tests."""
import json
import os
import sys
# Make the packaged scripts/ importable as the root — the hook scripts do:
# sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# so lib.* imports resolve relative to scripts/
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "hindsight_zcode", "hooks", "scripts")
if SCRIPTS_DIR not in sys.path:
sys.path.insert(0, os.path.abspath(SCRIPTS_DIR))
def make_hook_input(
prompt="What is the capital of France?",
session_id="sess-abc123",
cwd="/home/user/myproject",
transcript_path="",
workspace_roots=None,
**extras,
):
"""Build a ZCode (Claude Code protocol) hook input dict.
ZCode embeds the Claude Code agent runtime, so hook payloads carry
`prompt`, `session_id`, `transcript_path`, and `cwd`. We also include
`workspace_roots` for parity with how bank derivation sees the world.
"""
payload = {
"prompt": prompt,
"session_id": session_id,
"cwd": cwd,
"transcript_path": transcript_path,
"workspace_roots": workspace_roots or ["/home/user/myproject"],
"hook_event_name": "UserPromptSubmit",
}
payload.update(extras)
return payload
def make_transcript_file(tmp_path, messages, envelope_format=False, zcode_format=False):
"""Write messages as a JSONL transcript file.
By default writes the flat format {role, content} which
read_transcript() accepts. Set envelope_format=True to write the typed
transcript envelope {type, message: {role, content: [TextBlock]}}.
Set zcode_format=True to write the ephemeral Stop-hook shape
{message: {role, content: [TextBlock]}} with NO top-level type.
"""
f = tmp_path / "transcript-test.jsonl"
lines = []
for msg in messages:
role = msg["role"]
text = msg["content"]
if zcode_format:
envelope = {"message": {"role": role, "content": [{"type": "text", "text": text}]}}
lines.append(json.dumps(envelope))
elif envelope_format:
envelope = {
"type": role, # "user" / "assistant" as the event type
"message": {"role": role, "content": [{"type": "text", "text": text}]},
}
lines.append(json.dumps(envelope))
else:
lines.append(json.dumps(msg))
f.write_text("\n".join(lines))
return str(f)
def make_stop_input(response_text="world", session_id="sess-abc123", cwd="/home/user/myproject", **extras):
"""Build a ZCode `Stop`-hook input dict.
ZCode's Stop payload carries the assistant reply in `responseText` (and a
truncated `responsePreview`) plus both `session_id` and `sessionId`.
"""
payload = {
"responseText": response_text,
"responsePreview": response_text[:100],
"session_id": session_id,
"sessionId": session_id,
"cwd": cwd,
"workspace_roots": ["/home/user/myproject"],
"toolCallCount": 0,
"hookEventName": "Stop",
}
payload.update(extras)
return payload
def stash_prompt(tmp_path, session_id, prompt):
"""Seed the state file the recall hook would write, so retain can pair it."""
state_dir = tmp_path / ".hindsight" / "zcode" / "state"
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / f"last_prompt_{session_id}.json").write_text(
json.dumps({"prompt": prompt, "ts": "2024-01-15T00:00:00Z"})
)
def make_memory(text, mem_type="experience", mentioned_at="2024-01-15"):
return {"text": text, "type": mem_type, "mentioned_at": mentioned_at}
def make_user_config(tmp_path, overrides=None):
"""Write a ~/.hindsight/zcode.json in tmp_path with test defaults."""
hindsight_dir = tmp_path / ".hindsight"
hindsight_dir.mkdir(exist_ok=True)
config = {"retainEveryNTurns": 1}
if overrides:
config.update(overrides)
(hindsight_dir / "zcode.json").write_text(json.dumps(config))
class FakeHTTPResponse:
"""Minimal urllib response mock."""
def __init__(self, data, status=200):
self.status = status
self._data = json.dumps(data).encode()
def read(self):
return self._data
def __enter__(self):
return self
def __exit__(self, *_):
pass
@@ -0,0 +1,127 @@
"""Tests for lib/bank.py — bank ID derivation for the ZCode integration."""
import os
from lib.bank import derive_bank_id
def _cfg(**overrides):
base = {
"dynamicBankId": False,
"bankId": "zcode",
"bankIdPrefix": "",
"agentName": "zcode",
"dynamicBankGranularity": ["agent", "project"],
"bankMission": "",
"retainMission": None,
}
base.update(overrides)
return base
def _hook(session_id="sess-1", cwd="/home/user/myproject", workspace_roots=None):
return {
"session_id": session_id,
"cwd": cwd,
"workspace_roots": workspace_roots or [cwd],
}
class TestDeriveBankIdStatic:
def test_static_default_bank(self):
assert derive_bank_id(_hook(), _cfg()) == "zcode"
def test_static_custom_bank_id(self):
cfg = _cfg(bankId="my-agent")
assert derive_bank_id(_hook(), cfg) == "my-agent"
def test_static_with_prefix(self):
cfg = _cfg(bankId="bot", bankIdPrefix="prod")
assert derive_bank_id(_hook(), cfg) == "prod-bot"
def test_static_prefix_without_bankid_uses_default(self):
cfg = _cfg(bankId=None, bankIdPrefix="dev")
assert derive_bank_id(_hook(), cfg) == "dev-zcode"
class TestDeriveBankIdDynamic:
def test_dynamic_agent_project(self, monkeypatch):
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, agentName="mybot", dynamicBankGranularity=["agent", "project"])
result = derive_bank_id(_hook(cwd="/home/user/hindsight"), cfg)
assert result == "mybot::hindsight"
def test_dynamic_uses_zcode_project_dir_env(self, monkeypatch):
monkeypatch.setenv("ZCODE_PROJECT_DIR", "/work/myapp")
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"])
result = derive_bank_id(_hook(cwd="/should/not/use"), cfg)
assert "myapp" in result
def test_dynamic_uses_workspace_roots_when_no_env(self, monkeypatch):
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"])
result = derive_bank_id(
_hook(cwd="/x", workspace_roots=["/work/anotherapp"]),
cfg,
)
assert "anotherapp" in result
def test_dynamic_preserves_raw_special_chars(self, monkeypatch):
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"])
result = derive_bank_id(_hook(cwd="/home/user/my project"), cfg)
assert "my project" in result
assert "%" not in result
def test_dynamic_preserves_raw_utf8(self, monkeypatch):
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"])
result = derive_bank_id(_hook(cwd="/home/user/мой проект"), cfg)
assert "мой проект" in result
assert "%" not in result
def test_dynamic_session_field(self, monkeypatch):
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["session"])
result = derive_bank_id(_hook(session_id="abc-123"), cfg)
assert "abc-123" in result
def test_dynamic_conversation_id_also_works(self, monkeypatch):
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["session"])
hook = {"conversation_id": "conv-xyz", "cwd": "/x"}
result = derive_bank_id(hook, cfg)
assert "conv-xyz" in result
def test_dynamic_with_prefix(self, monkeypatch):
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["agent"], bankIdPrefix="v2")
result = derive_bank_id(_hook(), cfg)
assert result.startswith("v2-")
def test_dynamic_user_from_env(self, monkeypatch):
monkeypatch.setenv("HINDSIGHT_USER_ID", "user-456")
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["user"])
result = derive_bank_id(_hook(), cfg)
assert "user-456" in result
def test_dynamic_missing_env_uses_default(self, monkeypatch):
monkeypatch.delenv("HINDSIGHT_USER_ID", raising=False)
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["user"])
result = derive_bank_id(_hook(), cfg)
assert "anonymous" in result
def test_dynamic_empty_cwd_uses_unknown(self, monkeypatch):
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["project"])
result = derive_bank_id({"session_id": "s", "cwd": ""}, cfg)
assert "unknown" in result
def test_dynamic_git_project_alias(self, monkeypatch):
"""gitProject falls back to the same project resolver (kept for codex-compat)."""
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
cfg = _cfg(dynamicBankId=True, dynamicBankGranularity=["gitProject"])
result = derive_bank_id(_hook(cwd="/work/sharedrepo"), cfg)
assert "sharedrepo" in result
@@ -0,0 +1,50 @@
"""Tests for the hindsight-zcode CLI entry point."""
import json
from pathlib import Path
import pytest
from hindsight_zcode.cli import main
@pytest.fixture()
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
for var in ("HINDSIGHT_API_URL", "HINDSIGHT_API_TOKEN"):
monkeypatch.delenv(var, raising=False)
return tmp_path
def test_install_command_deploys(fake_home: Path) -> None:
rc = main(["install"])
assert rc == 0
assert (fake_home / ".zcode" / "hooks" / "hindsight" / "scripts" / "recall.py").exists()
assert (fake_home / ".zcode" / "cli" / "config.json").exists()
def test_install_passes_api_url_to_user_config(fake_home: Path) -> None:
main(["install", "--api-url", "http://localhost:8888"])
cfg = json.loads((fake_home / ".hindsight" / "zcode.json").read_text())
assert cfg["hindsightApiUrl"] == "http://localhost:8888"
def test_install_reads_api_url_from_env(fake_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("HINDSIGHT_API_URL", "http://env-url:9999")
main(["install"])
cfg = json.loads((fake_home / ".hindsight" / "zcode.json").read_text())
assert cfg["hindsightApiUrl"] == "http://env-url:9999"
def test_uninstall_command(fake_home: Path) -> None:
main(["install"])
rc = main(["uninstall"])
assert rc == 0
assert not (fake_home / ".zcode" / "hooks" / "hindsight").exists()
def test_no_subcommand_exits_nonzero() -> None:
with pytest.raises(SystemExit) as exc:
main([])
assert exc.value.code != 0
@@ -0,0 +1,75 @@
"""Tests for lib/client.py — Hindsight REST API client."""
from unittest.mock import patch
from conftest import FakeHTTPResponse
from lib.client import USER_AGENT, HindsightClient
class TestUserAgentHeader:
"""Regression tests for #1041.
The stdlib default ``Python-urllib/X.Y`` UA is blocked by Cloudflare
with error 1010, so every request must carry our identifying UA.
"""
def test_recall_sends_user_agent(self):
c = HindsightClient("http://localhost:9077")
captured = {}
def fake_open(req, timeout=None):
captured["ua"] = req.get_header("User-agent")
return FakeHTTPResponse({"results": []})
with patch("urllib.request.urlopen", side_effect=fake_open):
c.recall("bank", "query")
assert captured["ua"] == USER_AGENT
assert captured["ua"].startswith("hindsight-zcode/")
def test_health_check_sends_user_agent(self):
c = HindsightClient("http://localhost:9077")
captured = {}
def fake_open(req, timeout=None):
captured["ua"] = req.get_header("User-agent")
return FakeHTTPResponse({}, status=200)
with patch("urllib.request.urlopen", side_effect=fake_open):
c.health_check(timeout=1)
assert captured["ua"] == USER_AGENT
class TestURLValidation:
def test_rejects_non_http_scheme(self):
import pytest
with pytest.raises(ValueError):
HindsightClient("ftp://example.com")
def test_rejects_missing_hostname(self):
import pytest
with pytest.raises(ValueError):
HindsightClient("http://")
class TestRetainEncoding:
def test_retain_posts_async_true(self):
import json
c = HindsightClient("http://localhost:9077")
captured = {}
def fake_open(req, timeout=None):
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({})
with patch("urllib.request.urlopen", side_effect=fake_open):
c.retain("bank", "content", document_id="d1", context="zcode", tags=["x"])
assert captured["body"]["async"] is True
assert captured["body"]["items"][0]["context"] == "zcode"
assert captured["body"]["items"][0]["tags"] == ["x"]
assert captured["body"]["items"][0]["document_id"] == "d1"
@@ -0,0 +1,287 @@
"""Tests for lib/content.py — content processing for the ZCode integration."""
import json
import os
from lib.content import (
compose_recall_query,
format_current_time,
format_memories,
prepare_retention_transcript,
read_transcript,
slice_last_turns_by_user_boundary,
strip_memory_tags,
truncate_recall_query,
)
class TestStripMemoryTags:
def test_strips_hindsight_memories_block(self):
text = "real content <hindsight_memories>noise</hindsight_memories> more"
out = strip_memory_tags(text)
assert "noise" not in out
assert "real content" in out
assert "more" in out
def test_strips_relevant_memories_block(self):
text = "<relevant_memories>old</relevant_memories> fresh"
assert "old" not in strip_memory_tags(text)
assert "fresh" in strip_memory_tags(text)
def test_handles_no_tags(self):
assert strip_memory_tags("nothing to strip") == "nothing to strip"
class TestReadTranscript:
def test_returns_empty_for_missing_file(self, tmp_path):
assert read_transcript(str(tmp_path / "nope.jsonl")) == []
def test_reads_flat_format(self, tmp_path):
f = tmp_path / "transcript.jsonl"
f.write_text(
"\n".join(
[
json.dumps({"role": "user", "content": "hi"}),
json.dumps({"role": "assistant", "content": "hello"}),
]
)
)
msgs = read_transcript(str(f))
assert len(msgs) == 2
assert msgs[0] == {"role": "user", "content": "hi"}
assert msgs[1] == {"role": "assistant", "content": "hello"}
def test_reads_transcript_envelope(self, tmp_path):
f = tmp_path / "transcript.jsonl"
f.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"message": {"role": "user", "content": [{"type": "text", "text": "hi"}]},
}
),
json.dumps(
{
"type": "assistant",
"message": {"role": "assistant", "content": [{"type": "text", "text": "hello"}]},
}
),
]
)
)
msgs = read_transcript(str(f))
assert len(msgs) == 2
assert msgs[0] == {"role": "user", "content": "hi"}
assert msgs[1] == {"role": "assistant", "content": "hello"}
def test_reads_zcode_stop_transcript(self, tmp_path):
"""The ephemeral Stop transcript has NO top-level type — just {message}."""
f = tmp_path / "transcript.jsonl"
f.write_text(
"\n".join(
[
json.dumps(
{"message": {"role": "assistant", "content": [{"type": "text", "text": "hi from zcode"}]}}
),
]
)
)
msgs = read_transcript(str(f))
assert msgs == [{"role": "assistant", "content": "hi from zcode"}]
def test_reads_zcode_stop_transcript_string_content(self, tmp_path):
f = tmp_path / "transcript.jsonl"
f.write_text(json.dumps({"message": {"role": "assistant", "content": "plain string reply"}}))
msgs = read_transcript(str(f))
assert msgs == [{"role": "assistant", "content": "plain string reply"}]
def test_rich_reader_parses_zcode_stop_transcript(self, tmp_path):
f = tmp_path / "transcript.jsonl"
f.write_text(
"\n".join(
[
json.dumps({"message": {"role": "user", "content": [{"type": "text", "text": "a question"}]}}),
json.dumps({"message": {"role": "assistant", "content": [{"type": "text", "text": "an answer"}]}}),
]
)
)
msgs = read_transcript(str(f), include_tool_calls=True)
assert msgs[0]["role"] == "user"
assert msgs[1]["role"] == "assistant"
assert msgs[1]["content"][0]["text"] == "an answer"
def test_rich_reader_preserves_tool_calls(self, tmp_path):
f = tmp_path / "transcript.jsonl"
f.write_text(
"\n".join(
[
json.dumps(
{
"type": "user",
"message": {"role": "user", "content": [{"type": "text", "text": "list files"}]},
}
),
json.dumps(
{
"type": "assistant",
"message": {"role": "assistant", "content": [{"type": "text", "text": "running ls"}]},
}
),
json.dumps(
{
"type": "tool_call",
"name": "shell",
"args": {"command": "ls"},
"status": "completed",
"result": "a.txt\nb.txt",
}
),
]
)
)
msgs = read_transcript(str(f), include_tool_calls=True)
# 1 user message + 1 assistant message with structured content
assert len(msgs) == 2
assistant = msgs[1]
assert assistant["role"] == "assistant"
assert isinstance(assistant["content"], list)
tool_uses = [b for b in assistant["content"] if b.get("type") == "tool_use"]
tool_results = [b for b in assistant["content"] if b.get("type") == "tool_result"]
assert tool_uses and tool_uses[0]["name"] == "shell"
assert tool_results and "a.txt" in tool_results[0]["content"]
class TestComposeRecallQuery:
def test_single_turn_returns_latest(self):
q = compose_recall_query("hi", [], 1)
assert q == "hi"
def test_multi_turn_includes_context(self):
msgs = [
{"role": "user", "content": "I use Python"},
{"role": "assistant", "content": "Noted"},
]
q = compose_recall_query("What language?", msgs, 2)
assert "Python" in q
assert "Prior context:" in q
assert "What language?" in q
def test_skips_memory_tags(self):
msgs = [
{"role": "user", "content": "<hindsight_memories>noise</hindsight_memories> keep me"},
]
q = compose_recall_query("anything", msgs, 1)
assert q == "anything"
class TestTruncateRecallQuery:
def test_under_limit_unchanged(self):
q = "short query"
assert truncate_recall_query(q, q, 100) == q
def test_over_limit_drops_context(self):
query = "Prior context:\n\nuser: a\nassistant: b\n\nlatest"
truncated = truncate_recall_query(query, "latest", 20)
assert "Prior context:" not in truncated
assert "latest" in truncated
class TestSliceLastTurns:
def test_zero_turns(self):
assert slice_last_turns_by_user_boundary([{"role": "user", "content": "a"}], 0) == []
def test_slices_to_last_n_user_boundaries(self):
msgs = [
{"role": "user", "content": "1"},
{"role": "assistant", "content": "1a"},
{"role": "user", "content": "2"},
{"role": "assistant", "content": "2a"},
{"role": "user", "content": "3"},
{"role": "assistant", "content": "3a"},
]
sliced = slice_last_turns_by_user_boundary(msgs, 2)
# Should include user turn 2 onwards.
assert [m["content"] for m in sliced if m["role"] == "user"] == ["2", "3"]
class TestFormatMemories:
def test_empty(self):
assert format_memories([]) == ""
def test_with_results(self):
results = [
{"text": "Paris is in France", "type": "world", "mentioned_at": "2024-01-01"},
{"text": "User likes espresso", "type": "experience"},
]
out = format_memories(results)
assert "Paris is in France [world] (2024-01-01)" in out
assert "User likes espresso [experience]" in out
class TestFormatCurrentTime:
def test_format(self):
out = format_current_time()
assert len(out) == 16 # YYYY-MM-DD HH:MM
assert out[4] == "-"
assert out[10] == " "
class TestPrepareRetentionTranscript:
def test_full_window_text(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
text, count = prepare_retention_transcript(msgs, retain_full_window=True)
assert text is not None
assert "[role: user]" in text
assert "hi" in text
assert "[assistant:end]" in text
def test_last_turn_only(self):
msgs = [
{"role": "user", "content": "old"},
{"role": "assistant", "content": "old reply"},
{"role": "user", "content": "new question"},
{"role": "assistant", "content": "new reply"},
]
text, count = prepare_retention_transcript(msgs, retain_full_window=False)
assert "new question" in text
assert "old" not in text
assert count == 2
def test_empty_returns_none(self):
text, count = prepare_retention_transcript([])
assert text is None
assert count == 0
def test_strips_hindsight_memories_before_retaining(self):
msgs = [
{"role": "user", "content": "<hindsight_memories>x</hindsight_memories> actual question"},
]
text, _ = prepare_retention_transcript(msgs, retain_full_window=True)
assert "x" not in text
assert "actual question" in text
def test_rich_includes_tool_use(self):
msgs = [
{"role": "user", "content": [{"type": "text", "text": "list files"}]},
{
"role": "assistant",
"content": [
{"type": "text", "text": "running"},
{"type": "tool_use", "name": "shell", "input": {"command": "ls"}},
{"type": "tool_result", "content": "a.txt"},
],
},
]
text, count = prepare_retention_transcript(
msgs,
retain_full_window=True,
include_tool_calls=True,
)
assert text is not None
assert "tool_use" in text
assert "shell" in text
@@ -0,0 +1,511 @@
"""End-to-end tests for the ZCode hook scripts.
Mocks the ZCode hook runtime:
- stdin → io.StringIO(json.dumps(hook_input))
- stdout → io.StringIO() captured for assertions
- urllib.request.urlopen → fake HTTP responses
- HOME → tmp_path (isolates ~/.hindsight/zcode.json and state)
"""
import importlib.util
import io
import json
import os
import sys
from unittest.mock import patch
import pytest
from conftest import (
SCRIPTS_DIR,
FakeHTTPResponse,
make_hook_input,
make_memory,
make_stop_input,
make_transcript_file,
make_user_config,
stash_prompt,
)
def _run_hook(
module_name,
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=None,
user_config=None,
env_overrides=None,
set_default_api_url=True,
):
"""Import and run a hook script's main() with mocked stdin/stdout/HTTP."""
monkeypatch.setenv("HOME", str(tmp_path))
for k in list(os.environ):
if k.startswith("HINDSIGHT_"):
monkeypatch.delenv(k, raising=False)
if set_default_api_url:
monkeypatch.setenv("HINDSIGHT_API_URL", "http://fake:9077")
if env_overrides:
for k, v in env_overrides.items():
monkeypatch.setenv(k, v)
cfg = {"retainEveryNTurns": 1, "autoRecall": True, "autoRetain": True}
if user_config:
cfg.update(user_config)
make_user_config(tmp_path, cfg)
stdin_data = io.StringIO(json.dumps(hook_input))
stdout_capture = io.StringIO()
spec = importlib.util.spec_from_file_location(
module_name + "_fresh", os.path.join(SCRIPTS_DIR, f"{module_name}.py")
)
mod = importlib.util.module_from_spec(spec)
default_response = FakeHTTPResponse({"results": []})
side_effect = urlopen_side_effect or (lambda *a, **kw: default_response)
with (
patch("sys.stdin", stdin_data),
patch("sys.stdout", stdout_capture),
patch("urllib.request.urlopen", side_effect=side_effect),
):
spec.loader.exec_module(mod)
mod.main()
return stdout_capture.getvalue()
def _additional_context(data):
"""Extract additionalContext from the Claude Code UserPromptSubmit output."""
return data["hookSpecificOutput"]["additionalContext"]
# ---------------------------------------------------------------------------
# recall hook (UserPromptSubmit)
# ---------------------------------------------------------------------------
class TestRecallHook:
def test_outputs_additional_context_when_memories_found(self, monkeypatch, tmp_path):
memory = make_memory("Paris is the capital of France", "world")
response = FakeHTTPResponse({"results": [memory]})
hook_input = make_hook_input(prompt="What is the capital of France?")
output = _run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=lambda *a, **kw: response,
)
data = json.loads(output)
ctx = _additional_context(data)
assert "Paris is the capital of France" in ctx
assert "<hindsight_memories>" in ctx
def test_no_output_when_no_memories(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="hello there world")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
assert output.strip() == ""
def test_no_output_for_short_prompt(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="hi")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
assert output.strip() == ""
def test_stashes_prompt_for_retain(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="What is the capital of France?", session_id="sess-stash")
_run_hook("recall", hook_input, monkeypatch, tmp_path)
stash = tmp_path / ".hindsight" / "zcode" / "state" / "last_prompt_sess-stash.json"
assert stash.exists()
assert json.loads(stash.read_text())["prompt"] == "What is the capital of France?"
def test_stashes_prompt_even_when_too_short_for_recall(self, monkeypatch, tmp_path):
"""Short prompts skip recall injection but must still be stashed for retain."""
hook_input = make_hook_input(prompt="ls", session_id="sess-short")
output = _run_hook("recall", hook_input, monkeypatch, tmp_path)
assert output.strip() == ""
stash = tmp_path / ".hindsight" / "zcode" / "state" / "last_prompt_sess-short.json"
assert stash.exists()
assert json.loads(stash.read_text())["prompt"] == "ls"
def test_stashes_prompt_from_sessionid_camelcase(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="a longer prompt here", session_id="ignored")
hook_input.pop("session_id", None)
hook_input["sessionId"] = "sess-camel-recall"
_run_hook("recall", hook_input, monkeypatch, tmp_path)
stash = tmp_path / ".hindsight" / "zcode" / "state" / "last_prompt_sess-camel-recall.json"
assert stash.exists()
def test_graceful_on_api_error(self, monkeypatch, tmp_path):
def raise_error(*a, **kw):
raise OSError("connection refused")
hook_input = make_hook_input(prompt="What is my project about?")
output = _run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=raise_error,
)
assert output.strip() == ""
def test_output_format_matches_claude_code_spec(self, monkeypatch, tmp_path):
memory = make_memory("User prefers Python")
response = FakeHTTPResponse({"results": [memory]})
hook_input = make_hook_input(prompt="What language should I use?")
output = _run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=lambda *a, **kw: response,
)
data = json.loads(output)
# ZCode embeds the Claude Code runtime: recall emits the
# hookSpecificOutput.additionalContext envelope for UserPromptSubmit.
assert data["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"
assert "additionalContext" in data["hookSpecificOutput"]
def test_multi_turn_context_from_transcript(self, monkeypatch, tmp_path):
messages = [
{"role": "user", "content": "I use Python for all my scripts"},
{"role": "assistant", "content": "Noted!"},
]
transcript = make_transcript_file(tmp_path, messages)
captured_body = {}
def capture_and_respond(req, timeout=None):
if "/recall" in req.full_url:
captured_body["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({"results": []})
hook_input = make_hook_input(prompt="What language should I use?", transcript_path=transcript)
_run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=capture_and_respond,
user_config={"recallContextTurns": 2},
)
if "body" in captured_body:
assert "Python" in captured_body["body"].get("query", "")
def test_recall_timeout_is_configurable(self, monkeypatch, tmp_path):
memory = make_memory("User prefers Python")
captured = {}
def capture_timeout(req, timeout=None):
captured["timeout"] = timeout
return FakeHTTPResponse({"results": [memory]})
hook_input = make_hook_input(prompt="What language should I use?")
_run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=capture_timeout,
user_config={"recallTimeout": 42},
)
assert captured["timeout"] == 42
def test_disabled_auto_recall_produces_no_output(self, monkeypatch, tmp_path):
hook_input = make_hook_input(prompt="What is the capital of France?")
output = _run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
user_config={"autoRecall": False},
)
assert output.strip() == ""
def test_recall_never_raises_on_memories(self, monkeypatch, tmp_path):
"""Memory injection must be a clean UserPromptSubmit envelope."""
response = FakeHTTPResponse({"results": [make_memory("anything")]})
hook_input = make_hook_input(prompt="anything goes here")
output = _run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=lambda *a, **kw: response,
)
data = json.loads(output)
assert _additional_context(data)
def test_uses_workspace_roots_for_project(self, monkeypatch, tmp_path):
"""When ZCODE_PROJECT_DIR is unset, falls back to workspace_roots[0]."""
memory = make_memory("hi")
captured = {}
def capture(req, timeout=None):
captured["ua"] = req.get_header("User-agent")
return FakeHTTPResponse({"results": [memory]})
# Strip ZCODE_PROJECT_DIR to force fallback path.
monkeypatch.delenv("ZCODE_PROJECT_DIR", raising=False)
hook_input = make_hook_input(prompt="anything goes here", workspace_roots=["/work/myapp"])
_run_hook(
"recall",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=capture,
)
assert captured.get("ua", "").startswith("hindsight-zcode/")
# ---------------------------------------------------------------------------
# retain hook (Stop)
# ---------------------------------------------------------------------------
def _retain_body_capture(captured):
"""A urlopen side-effect that captures the retain POST body."""
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({"status": "accepted"})
return capture
class TestRetainHook:
def test_pairs_stashed_prompt_with_response_text(self, monkeypatch, tmp_path):
"""Retain assembles the turn from the stashed prompt + responseText."""
stash_prompt(tmp_path, "sess-abc123", "how do I list files")
captured = {}
hook_input = make_stop_input(response_text="use the ls command", session_id="sess-abc123")
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=_retain_body_capture(captured))
assert "body" in captured, "retain API was not called"
content = captured["body"]["items"][0]["content"]
assert "how do I list files" in content
assert "use the ls command" in content
def test_retains_assistant_only_when_no_stashed_prompt(self, monkeypatch, tmp_path):
captured = {}
hook_input = make_stop_input(response_text="standalone reply", session_id="sess-noprompt")
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=_retain_body_capture(captured))
assert "body" in captured, "retain API was not called"
content = captured["body"]["items"][0]["content"]
assert "standalone reply" in content
def test_accepts_sessionid_camelcase(self, monkeypatch, tmp_path):
stash_prompt(tmp_path, "sess-camel", "prompt text here")
captured = {}
hook_input = make_stop_input(response_text="reply", session_id="ignored")
# Only the camelCase key is present.
hook_input.pop("session_id", None)
hook_input["sessionId"] = "sess-camel"
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=_retain_body_capture(captured))
assert "prompt text here" in captured["body"]["items"][0]["content"]
def test_no_retain_when_no_text_and_no_prompt(self, monkeypatch, tmp_path):
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url:
captured["called"] = True
return FakeHTTPResponse({})
hook_input = make_stop_input(response_text="", session_id="sess-empty")
hook_input["responsePreview"] = ""
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
assert "called" not in captured
def test_falls_back_to_transcript_for_assistant_text(self, monkeypatch, tmp_path):
"""With no responseText, retain parses the ephemeral ZCode transcript."""
transcript = make_transcript_file(
tmp_path,
[{"role": "assistant", "content": "from the transcript"}],
zcode_format=True,
)
captured = {}
hook_input = make_stop_input(response_text="", session_id="sess-transcript")
hook_input["responsePreview"] = ""
hook_input["transcript_path"] = transcript
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=_retain_body_capture(captured))
assert "body" in captured, "retain API was not called"
assert "from the transcript" in captured["body"]["items"][0]["content"]
def test_strips_memory_tags_before_retaining(self, monkeypatch, tmp_path):
stash_prompt(
tmp_path,
"sess-tags",
"<hindsight_memories>old memories</hindsight_memories> actual question",
)
captured = {}
hook_input = make_stop_input(response_text="sure!", session_id="sess-tags")
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=_retain_body_capture(captured))
content = captured["body"]["items"][0]["content"]
assert "old memories" not in content
assert "actual question" in content
def test_retain_posts_async_true(self, monkeypatch, tmp_path):
stash_prompt(tmp_path, "sess-abc123", "hello there")
captured = {}
hook_input = make_stop_input(response_text="world", session_id="sess-abc123")
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=_retain_body_capture(captured))
assert captured["body"].get("async") is True
def test_retain_includes_zcode_context_label(self, monkeypatch, tmp_path):
stash_prompt(tmp_path, "sess-abc123", "hello there")
captured = {}
hook_input = make_stop_input(response_text="world", session_id="sess-abc123")
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=_retain_body_capture(captured))
assert captured["body"]["items"][0]["context"] == "zcode"
def test_retain_skips_below_every_n_turns_threshold(self, monkeypatch, tmp_path):
captured = {}
def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["called"] = True
return FakeHTTPResponse({})
hook_input = make_stop_input(response_text="world", session_id="sess-abc123")
_run_hook(
"retain",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=capture,
user_config={"retainEveryNTurns": 3},
)
assert "called" not in captured
def test_document_id_is_per_turn(self, monkeypatch, tmp_path):
"""Each turn gets a distinct document_id so prior turns aren't overwritten."""
stash_prompt(tmp_path, "sess-doc-test", "question")
captured = {}
hook_input = make_stop_input(response_text="answer", session_id="sess-doc-test")
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=_retain_body_capture(captured))
doc_id = captured["body"]["items"][0]["document_id"]
assert doc_id.startswith("sess-doc-test-")
assert doc_id != "sess-doc-test"
def test_graceful_on_retain_api_error(self, monkeypatch, tmp_path):
stash_prompt(tmp_path, "sess-abc123", "test")
def raise_error(req, timeout=None):
if "/memories" in req.full_url:
raise OSError("connection refused")
return FakeHTTPResponse({})
hook_input = make_stop_input(response_text="response", session_id="sess-abc123")
# Should not raise
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=raise_error)
def test_disabled_auto_retain_does_not_call_api(self, monkeypatch, tmp_path):
stash_prompt(tmp_path, "sess-abc123", "hello")
captured = {}
def capture(req, timeout=None):
captured["called"] = True
return FakeHTTPResponse({})
hook_input = make_stop_input(response_text="world", session_id="sess-abc123")
_run_hook(
"retain",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=capture,
user_config={"autoRetain": False},
)
assert "called" not in captured
def test_stop_hook_emits_no_stdout(self, monkeypatch, tmp_path):
"""The Stop hook stores memory silently — it emits no stdout."""
stash_prompt(tmp_path, "sess-abc123", "hi there")
hook_input = make_stop_input(response_text="reply", session_id="sess-abc123")
output = _run_hook(
"retain",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=lambda *a, **kw: FakeHTTPResponse({}),
)
assert output.strip() == ""
# ---------------------------------------------------------------------------
# SessionStart hook
# ---------------------------------------------------------------------------
class TestSessionStartHook:
def test_no_output_when_server_reachable(self, monkeypatch, tmp_path):
"""SessionStart is fire-and-forget: no banner, no additionalContext.
Mirrors codex and claude-code: the hook just health-checks and
pre-warms the daemon. Any user-facing output here is invented
surface and a divergence risk — the recall output is the only
agent-visible channel.
"""
health_response = FakeHTTPResponse({}, status=200)
def health_then_empty(req, timeout=None):
if "/health" in req.full_url:
return health_response
return FakeHTTPResponse({})
hook_input = make_hook_input()
output = _run_hook(
"session_start",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=health_then_empty,
set_default_api_url=False,
)
assert output.strip() == ""
def test_no_output_when_server_unreachable(self, monkeypatch, tmp_path):
def raise_error(req, timeout=None):
raise OSError("connection refused")
hook_input = make_hook_input()
output = _run_hook(
"session_start",
hook_input,
monkeypatch,
tmp_path,
urlopen_side_effect=raise_error,
set_default_api_url=False,
)
# Fire-and-forget — never raise. Output is empty in both paths.
assert output.strip() == ""
def test_both_disabled_produces_no_output(self, monkeypatch, tmp_path):
hook_input = make_hook_input()
output = _run_hook(
"session_start",
hook_input,
monkeypatch,
tmp_path,
user_config={"autoRecall": False, "autoRetain": False},
)
assert output.strip() == ""
@@ -0,0 +1,204 @@
"""Tests for install.py — ZCode Hindsight integration installer."""
import json
from pathlib import Path
import pytest
from hindsight_zcode import install
@pytest.fixture()
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Isolate HOME so install/uninstall touch only tmp_path."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
return tmp_path
def _config(home: Path) -> dict:
return json.loads((home / ".zcode" / "cli" / "config.json").read_text())
def _events(config: dict) -> dict:
return config["hooks"]["events"]
def _hindsight_defs(config: dict, event: str) -> list:
"""Hindsight hook definitions registered for an event."""
return [d for d in _events(config).get(event, []) if "hooks/hindsight" in json.dumps(d)]
# ---------------------------------------------------------------------------
# render_hooks_events — ZCode-native hook schema
# ---------------------------------------------------------------------------
def test_render_hooks_substitutes_scripts_dir() -> None:
events = install.render_hooks_events(Path("/opt/hooks/scripts"))
commands = json.dumps(events)
assert install.SCRIPTS_PLACEHOLDER not in commands
assert "/opt/hooks/scripts" in commands
def test_render_hooks_covers_wired_events() -> None:
events = install.render_hooks_events(Path("/opt/hooks/scripts"))
# ZCode has no SessionEnd — retain rides Stop.
assert set(events) == {"SessionStart", "UserPromptSubmit", "Stop"}
def test_render_hooks_uses_process_schema() -> None:
events = install.render_hooks_events(Path("/opt/hooks/scripts"))
inner = events["UserPromptSubmit"][0]["hooks"][0]
# ZCode-native shape: process command + args[] + timeoutMs.
assert inner["type"] == "process"
assert inner["command"] == "python3"
assert any("recall.py" in a for a in inner["args"])
assert inner["timeoutMs"] == 12000
def test_render_hooks_no_async_key() -> None:
events = install.render_hooks_events(Path("/opt/hooks/scripts"))
inner = events["Stop"][0]["hooks"][0]
# async has no effect in ZCode; hooks run inline.
assert "async" not in inner
assert inner["timeoutMs"] == 15000
# ---------------------------------------------------------------------------
# run_install — full install
# ---------------------------------------------------------------------------
def test_install_copies_scripts_and_lib(fake_home: Path) -> None:
install.run_install()
scripts = fake_home / ".zcode" / "hooks" / "hindsight" / "scripts"
assert (scripts / "recall.py").exists()
assert (scripts / "lib" / "client.py").exists()
def test_install_writes_settings_with_version(fake_home: Path) -> None:
install.run_install()
settings_path = fake_home / ".zcode" / "hooks" / "hindsight" / "settings.json"
settings = json.loads(settings_path.read_text())
# Version is stamped from package metadata, not the shipped template.
assert "version" in settings
assert settings["bankId"] == "zcode"
def test_install_registers_hooks_in_zcode_config(fake_home: Path) -> None:
install.run_install()
config = _config(fake_home)
# Config hooks are disabled by default — install must force them on.
assert config["hooks"]["enabled"] is True
assert config["hooks"]["maxOutputBytes"] == install.MAX_OUTPUT_BYTES
events = _events(config)
assert "UserPromptSubmit" in events
inner = events["UserPromptSubmit"][0]["hooks"][0]
script_arg = inner["args"][0]
assert str(fake_home) in script_arg # absolute path to the installed script
assert "hooks/hindsight" in script_arg
def test_install_seeds_user_config(fake_home: Path) -> None:
install.run_install(api_url="https://api.example.com", api_token="hsk_x")
cfg = json.loads((fake_home / ".hindsight" / "zcode.json").read_text())
assert cfg["hindsightApiUrl"] == "https://api.example.com"
assert cfg["hindsightApiToken"] == "hsk_x"
def test_install_preserves_existing_user_config(fake_home: Path) -> None:
user_config = fake_home / ".hindsight" / "zcode.json"
user_config.parent.mkdir(parents=True)
user_config.write_text(json.dumps({"hindsightApiToken": "keep-me"}))
install.run_install(api_url="https://override.example.com")
cfg = json.loads(user_config.read_text())
assert cfg == {"hindsightApiToken": "keep-me"} # untouched
# ---------------------------------------------------------------------------
# merge — preserve foreign config keys/hooks, stay idempotent
# ---------------------------------------------------------------------------
def test_merge_preserves_foreign_config_keys(fake_home: Path) -> None:
config_json = fake_home / ".zcode" / "cli" / "config.json"
config_json.parent.mkdir(parents=True)
config_json.write_text(json.dumps({"model": "glm-4.6", "theme": "dark"}))
install.run_install()
config = _config(fake_home)
# Non-hooks keys are preserved untouched.
assert config["model"] == "glm-4.6"
assert config["theme"] == "dark"
assert "UserPromptSubmit" in _events(config)
def test_merge_preserves_foreign_hooks(fake_home: Path) -> None:
config_json = fake_home / ".zcode" / "cli" / "config.json"
config_json.parent.mkdir(parents=True)
config_json.write_text(
json.dumps(
{
"hooks": {
"enabled": True,
"maxOutputBytes": 4096,
"events": {
"Stop": [{"hooks": [{"type": "process", "command": "echo", "args": ["other"]}]}],
},
}
}
)
)
install.run_install()
config = _config(fake_home)
stop_cmds = json.dumps(_events(config)["Stop"])
assert "other" in stop_cmds # foreign hook preserved
assert "retain.py" in stop_cmds # ours added
# A pre-existing maxOutputBytes is respected, not clobbered.
assert config["hooks"]["maxOutputBytes"] == 4096
def test_reinstall_does_not_duplicate(fake_home: Path) -> None:
install.run_install()
install.run_install()
config = _config(fake_home)
assert len(_hindsight_defs(config, "Stop")) == 1
# ---------------------------------------------------------------------------
# uninstall
# ---------------------------------------------------------------------------
def test_uninstall_removes_scripts_and_strips_hooks(fake_home: Path) -> None:
install.run_install()
install.run_uninstall()
assert not (fake_home / ".zcode" / "hooks" / "hindsight").exists()
config = _config(fake_home)
events = config["hooks"].get("events", {})
assert all("hooks/hindsight" not in json.dumps(d) for defs in events.values() for d in defs)
def test_uninstall_preserves_user_config(fake_home: Path) -> None:
install.run_install(api_url="https://api.example.com")
install.run_uninstall()
assert (fake_home / ".hindsight" / "zcode.json").exists()
def test_uninstall_preserves_foreign_hooks(fake_home: Path) -> None:
config_json = fake_home / ".zcode" / "cli" / "config.json"
config_json.parent.mkdir(parents=True)
foreign_stop = [{"hooks": [{"type": "process", "command": "echo", "args": ["other"]}]}]
config_json.write_text(json.dumps({"hooks": {"events": {"Stop": foreign_stop}}}))
install.run_install()
install.run_uninstall()
config = _config(fake_home)
assert _events(config)["Stop"] == foreign_stop
@@ -0,0 +1,52 @@
"""Validate the Claude Code / ZCode plugin manifest.
The package ships two install paths that share the same hook scripts:
1. pip installer (`hindsight-zcode install`) — writes ~/.zcode/cli/config.json
2. Claude Code plugin — `.claude-plugin/plugin.json` + `hooks/hooks.json`,
installed via a marketplace and auto-registered by the host.
This test guards the plugin path: the manifest is well-formed, its version
matches the package, and every hook command points at a script that exists.
"""
import json
import re
from pathlib import Path
PKG_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_JSON = PKG_ROOT / ".claude-plugin" / "plugin.json"
HOOKS_JSON = PKG_ROOT / "hooks" / "hooks.json"
SCRIPTS_DIR = PKG_ROOT / "hindsight_zcode" / "hooks" / "scripts"
def _package_version() -> str:
text = (PKG_ROOT / "pyproject.toml").read_text()
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
assert match, "version not found in pyproject.toml"
return match.group(1)
def test_plugin_manifest_is_valid():
manifest = json.loads(PLUGIN_JSON.read_text())
assert manifest["name"] == "hindsight-zcode"
assert manifest["description"]
assert manifest["license"] == "MIT"
def test_plugin_version_matches_package():
manifest = json.loads(PLUGIN_JSON.read_text())
assert manifest["version"] == _package_version()
def test_hooks_reference_only_supported_zcode_events():
hooks = json.loads(HOOKS_JSON.read_text())["hooks"]
# ZCode supports exactly these events; notably there is no SessionEnd.
assert set(hooks) == {"SessionStart", "UserPromptSubmit", "Stop"}
def test_hook_commands_point_at_existing_scripts():
hooks = json.loads(HOOKS_JSON.read_text())["hooks"]
referenced = re.findall(r"scripts/(\w+\.py)", json.dumps(hooks))
assert referenced, "no script references found in hooks.json"
for script in referenced:
assert (SCRIPTS_DIR / script).is_file(), f"hooks.json references missing script: {script}"
+108
View File
@@ -0,0 +1,108 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "hindsight-zcode"
version = "0.1.0"
source = { editable = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "ruff", specifier = ">=0.8.0" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "ruff"
version = "0.15.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
{ url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
{ url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
{ url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
{ url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
{ url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
{ url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
]
+1 -1
View File
@@ -13,7 +13,7 @@ print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
VALID_INTEGRATIONS=("ag2" "agent-framework" "agentcore" "agno" "aider" "ai-sdk" "autogen" "chat" "claude-agent-sdk" "claude-code" "cline" "cloudflare-oauth-proxy" "codex" "composio" "continue" "crewai" "cursor" "cursor-cli" "devin-desktop" "dify" "eve" "flowise" "gemini-spark" "github-copilot" "google-adk" "haystack" "langgraph" "litellm" "llamaindex" "n8n" "nemoclaw" "obsidian" "omo" "openai-agents" "openclaw" "opencode" "openhands" "paperclip" "pipecat" "pydantic-ai" "roo-code" "smolagents" "strands" "superagent" "vapi" "zed")
VALID_INTEGRATIONS=("ag2" "agent-framework" "agentcore" "agno" "aider" "ai-sdk" "autogen" "chat" "claude-agent-sdk" "claude-code" "cline" "cloudflare-oauth-proxy" "codex" "composio" "continue" "crewai" "cursor" "cursor-cli" "devin-desktop" "dify" "eve" "flowise" "gemini-spark" "github-copilot" "google-adk" "haystack" "langgraph" "litellm" "llamaindex" "n8n" "nemoclaw" "obsidian" "omo" "openai-agents" "openclaw" "opencode" "openhands" "paperclip" "pipecat" "pydantic-ai" "roo-code" "smolagents" "strands" "superagent" "vapi" "zcode" "zed")
usage() {
print_error "Usage: $0 <integration> <version>"