Closes the asymmetry the user surfaced: before this, Claude Code captured every turn (Stop hook) but retrieval only happened when Claude chose to call atocore_context (opt-in MCP tool). OpenClaw had both sides covered after 7I; Claude Code did not. Now symmetric. Every Claude Code prompt is auto-sent to /context/build and the returned pack is prepended via hookSpecificOutput.additionalContext — same as what OpenClaw's before_agent_start hook now does. - deploy/hooks/inject_context.py — UserPromptSubmit hook. Fail-open (always exit 0). Skips short/XML prompts. 5s timeout. Project inference mirrors capture_stop.py cwd→slug table. Kill switch: ATOCORE_CONTEXT_DISABLED=1. - ~/.claude/settings.json registered the hook (local config, not committed; copy-paste snippet in docs/capture-surfaces.md). - Removed /wiki/capture from topnav. Endpoint still exists but the page is now labeled "fallback only" with a warning banner. The sanctioned surfaces are Claude Code + OpenClaw; manual paste is explicitly not the design. - docs/capture-surfaces.md — scope statement: two surfaces, nothing else. Anthropic API polling explicitly prohibited. Tests: +8 for inject_context.py (exit 0 on all failure modes, kill switch, short prompt filter, XML filter, bad stdin, mock-server success shape, project inference from cwd). Updated 2 wiki tests for the topnav change. 450 → 459. Verified live with real AtoCore: injected 2979 chars of atocore project context on a cwd-matched prompt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
5.4 KiB
Python
175 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Claude Code UserPromptSubmit hook: inject AtoCore context.
|
|
|
|
Mirrors the OpenClaw 7I pattern on the Claude Code side. Every user
|
|
prompt submitted to Claude Code is (a) sent to /context/build on the
|
|
AtoCore API, and (b) the returned context pack is prepended to the
|
|
prompt the LLM sees — so Claude Code answers grounded in what AtoCore
|
|
already knows, same as OpenClaw now does.
|
|
|
|
Contract per Claude Code hooks spec:
|
|
stdin: JSON with `prompt`, `session_id`, `transcript_path`, `cwd`,
|
|
`hook_event_name`, etc.
|
|
stdout on success: JSON
|
|
{"hookSpecificOutput":
|
|
{"hookEventName": "UserPromptSubmit",
|
|
"additionalContext": "<pack>"}}
|
|
exit 0 always — fail open. An unreachable AtoCore must never block
|
|
the user's prompt.
|
|
|
|
Environment variables:
|
|
ATOCORE_URL base URL (default http://dalidou:8100)
|
|
ATOCORE_CONTEXT_DISABLED set to "1" to disable injection
|
|
ATOCORE_CONTEXT_BUDGET max chars of injected pack (default 4000)
|
|
ATOCORE_CONTEXT_TIMEOUT HTTP timeout in seconds (default 5)
|
|
|
|
Usage in ~/.claude/settings.json:
|
|
"UserPromptSubmit": [{
|
|
"matcher": "",
|
|
"hooks": [{
|
|
"type": "command",
|
|
"command": "python /path/to/inject_context.py",
|
|
"timeout": 10
|
|
}]
|
|
}]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
ATOCORE_URL = os.environ.get("ATOCORE_URL", "http://dalidou:8100")
|
|
CONTEXT_TIMEOUT = float(os.environ.get("ATOCORE_CONTEXT_TIMEOUT", "5"))
|
|
CONTEXT_BUDGET = int(os.environ.get("ATOCORE_CONTEXT_BUDGET", "4000"))
|
|
|
|
# Don't spend an API call on trivial acks or slash commands.
|
|
MIN_PROMPT_LENGTH = 15
|
|
|
|
|
|
# Project inference table — kept in sync with capture_stop.py so both
|
|
# hooks agree on what project a Claude Code session belongs to.
|
|
_VAULT = "C:\\Users\\antoi\\antoine\\My Libraries\\Antoine Brain Extension"
|
|
_PROJECT_PATH_MAP: dict[str, str] = {
|
|
f"{_VAULT}\\2-Projects\\P04-GigaBIT-M1": "p04-gigabit",
|
|
f"{_VAULT}\\2-Projects\\P10-Interferometer": "p05-interferometer",
|
|
f"{_VAULT}\\2-Projects\\P11-Polisher-Fullum": "p06-polisher",
|
|
f"{_VAULT}\\2-Projects\\P08-ABB-Space-Mirror": "abb-space",
|
|
f"{_VAULT}\\2-Projects\\I01-Atomizer": "atomizer-v2",
|
|
f"{_VAULT}\\2-Projects\\I02-AtoCore": "atocore",
|
|
"C:\\Users\\antoi\\ATOCore": "atocore",
|
|
"C:\\Users\\antoi\\Polisher-Sim": "p06-polisher",
|
|
"C:\\Users\\antoi\\Fullum-Interferometer": "p05-interferometer",
|
|
"C:\\Users\\antoi\\Atomizer-V2": "atomizer-v2",
|
|
}
|
|
|
|
|
|
def _infer_project(cwd: str) -> str:
|
|
if not cwd:
|
|
return ""
|
|
norm = os.path.normpath(cwd).lower()
|
|
for path_prefix, project_id in _PROJECT_PATH_MAP.items():
|
|
if norm.startswith(os.path.normpath(path_prefix).lower()):
|
|
return project_id
|
|
return ""
|
|
|
|
|
|
def _emit_empty() -> None:
|
|
"""Exit 0 with no additionalContext — equivalent to no-op."""
|
|
sys.exit(0)
|
|
|
|
|
|
def _emit_context(pack: str) -> None:
|
|
"""Write the hook output JSON and exit 0."""
|
|
out = {
|
|
"hookSpecificOutput": {
|
|
"hookEventName": "UserPromptSubmit",
|
|
"additionalContext": pack,
|
|
}
|
|
}
|
|
sys.stdout.write(json.dumps(out))
|
|
sys.exit(0)
|
|
|
|
|
|
def main() -> None:
|
|
if os.environ.get("ATOCORE_CONTEXT_DISABLED") == "1":
|
|
_emit_empty()
|
|
|
|
try:
|
|
raw = sys.stdin.read()
|
|
if not raw.strip():
|
|
_emit_empty()
|
|
hook_data = json.loads(raw)
|
|
except Exception as exc:
|
|
# Bad stdin → nothing to do
|
|
print(f"inject_context: bad stdin: {exc}", file=sys.stderr)
|
|
_emit_empty()
|
|
|
|
prompt = (hook_data.get("prompt") or "").strip()
|
|
cwd = hook_data.get("cwd", "")
|
|
|
|
if len(prompt) < MIN_PROMPT_LENGTH:
|
|
_emit_empty()
|
|
|
|
# Skip meta / system prompts that start with '<' (XML tags etc.)
|
|
if prompt.startswith("<"):
|
|
_emit_empty()
|
|
|
|
project = _infer_project(cwd)
|
|
|
|
body = json.dumps({
|
|
"prompt": prompt,
|
|
"project": project,
|
|
"char_budget": CONTEXT_BUDGET,
|
|
}).encode("utf-8")
|
|
|
|
req = urllib.request.Request(
|
|
f"{ATOCORE_URL}/context/build",
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=CONTEXT_TIMEOUT)
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
except urllib.error.URLError as exc:
|
|
# AtoCore unreachable — fail open
|
|
print(f"inject_context: atocore unreachable: {exc}", file=sys.stderr)
|
|
_emit_empty()
|
|
except Exception as exc:
|
|
print(f"inject_context: request failed: {exc}", file=sys.stderr)
|
|
_emit_empty()
|
|
|
|
pack = (data.get("formatted_context") or "").strip()
|
|
if not pack:
|
|
_emit_empty()
|
|
|
|
# Safety truncate. /context/build respects the budget we sent, but
|
|
# be defensive in case of a regression.
|
|
if len(pack) > CONTEXT_BUDGET + 500:
|
|
pack = pack[:CONTEXT_BUDGET] + "\n\n[context truncated]"
|
|
|
|
# Wrap so the LLM knows this is injected grounding, not user text.
|
|
wrapped = (
|
|
"---\n"
|
|
"AtoCore-injected context for this prompt "
|
|
f"(project={project or '(none)'}):\n\n"
|
|
f"{pack}\n"
|
|
"---"
|
|
)
|
|
|
|
print(
|
|
f"inject_context: injected {len(pack)} chars "
|
|
f"(project={project or 'none'}, prompt_chars={len(prompt)})",
|
|
file=sys.stderr,
|
|
)
|
|
_emit_context(wrapped)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|