From 9c91d778d991debae6245344ba0b79559846e45c Mon Sep 17 00:00:00 2001 From: Anto01 Date: Sun, 19 Apr 2026 12:01:41 -0400 Subject: [PATCH] feat: Claude Code context injection (UserPromptSubmit hook) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- deploy/hooks/inject_context.py | 174 ++++++++++++++++++++++++++ docs/capture-surfaces.md | 45 +++++++ src/atocore/engineering/wiki.py | 22 +++- tests/test_inject_context_hook.py | 198 ++++++++++++++++++++++++++++++ tests/test_wiki_pages.py | 25 ++-- 5 files changed, 448 insertions(+), 16 deletions(-) create mode 100644 deploy/hooks/inject_context.py create mode 100644 docs/capture-surfaces.md create mode 100644 tests/test_inject_context_hook.py diff --git a/deploy/hooks/inject_context.py b/deploy/hooks/inject_context.py new file mode 100644 index 0000000..70bdd33 --- /dev/null +++ b/deploy/hooks/inject_context.py @@ -0,0 +1,174 @@ +#!/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": ""}} + 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() diff --git a/docs/capture-surfaces.md b/docs/capture-surfaces.md new file mode 100644 index 0000000..6d0de23 --- /dev/null +++ b/docs/capture-surfaces.md @@ -0,0 +1,45 @@ +# AtoCore — sanctioned capture surfaces + +**Scope statement**: AtoCore captures conversations from **two surfaces only**. Everything else is intentionally out of scope. + +| Surface | Hooks | Status | +|---|---|---| +| **Claude Code** (local CLI) | `Stop` (capture) + `UserPromptSubmit` (context injection) | both installed | +| **OpenClaw** (agent framework on T420) | `before_agent_start` (context injection) + `llm_output` (capture) | both installed (v0.2.0 plugin, Phase 7I) | + +Both surfaces are **symmetric** — push (capture) and pull (context injection on prompt submit) — so AtoCore learns from every turn AND every turn is grounded in what AtoCore already knows. + +## Why these two? + +- **Stable hook APIs.** Claude Code exposes `Stop` and `UserPromptSubmit` lifecycle hooks with documented JSON contracts. OpenClaw exposes `before_agent_start` and `llm_output`. Both run locally where we control the process. +- **Passive from the user's perspective.** No paste, no manual capture command, no "remember this" prompt. You just use the tool and AtoCore absorbs everything durable. +- **Failure is graceful.** If AtoCore is down, hooks exit 0 with no output — the user's turn proceeds uninterrupted. + +## Why not Claude Desktop / Claude.ai web / Claude mobile / ChatGPT / …? + +- Claude Desktop has MCP but no `Stop`-equivalent hook for auto-capture; auto-capture would require system-prompt coercion ("call atocore_remember every turn"), which is fragile. +- Claude.ai web has no hook surface — would need a browser extension (real project, not shipped). +- Claude mobile app has neither hooks nor MCP — nothing to wire into. +- ChatGPT etc. — same as above. + +**Anthropic API log polling is explicitly prohibited.** + +If you find yourself wanting to capture from one of these, the real answer is: use Claude Code or OpenClaw for the work that matters. Don't paste chat transcripts into AtoCore — that contradicts the whole design principle of passive capture. + +A `/wiki/capture` fallback form still exists (the endpoint `/interactions` is public) but it is **not promoted in the UI** and is documented as a last-resort escape hatch. If you're reaching for it, something is wrong with your workflow, not with AtoCore. + +## Hook files + +- `deploy/hooks/capture_stop.py` — Claude Code Stop → POSTs `/interactions` +- `deploy/hooks/inject_context.py` — Claude Code UserPromptSubmit → POSTs `/context/build`, returns pack via `hookSpecificOutput.additionalContext` +- `openclaw-plugins/atocore-capture/index.js` — OpenClaw plugin v0.2.0: capture + context injection + +Both Claude Code hooks share a `_infer_project` table mapping cwd to project slug. Keep them in sync when adding a new project path. + +## Kill switches + +- `ATOCORE_CAPTURE_DISABLED=1` → skip Stop capture +- `ATOCORE_CONTEXT_DISABLED=1` → skip UserPromptSubmit injection +- OpenClaw plugin config `injectContext: false` → skip context injection (capture still fires) + +All three are documented in the respective hook/plugin files. diff --git a/src/atocore/engineering/wiki.py b/src/atocore/engineering/wiki.py index 5587b5c..bfd253e 100644 --- a/src/atocore/engineering/wiki.py +++ b/src/atocore/engineering/wiki.py @@ -29,7 +29,6 @@ from atocore.projects.registry import load_project_registry _TOP_NAV_LINKS = [ ("🏠 Home", "/wiki"), ("📡 Activity", "/wiki/activity"), - ("📥 Capture", "/wiki/capture"), ("🔀 Triage", "/admin/triage"), ("📊 Dashboard", "/admin/dashboard"), ] @@ -337,16 +336,27 @@ def render_search(query: str) -> str: # --------------------------------------------------------------------- -# Phase 7I follow-up — /wiki/capture: paste mobile/desktop chats +# /wiki/capture — DEPRECATED emergency paste-in form. +# Kept as an endpoint because POST /interactions is public anyway, but +# REMOVED from the topnav so it's not promoted as the capture path. +# The sanctioned surfaces are Claude Code (Stop + UserPromptSubmit +# hooks) and OpenClaw (capture plugin with 7I context injection). +# This form is explicitly a last-resort for when someone has to feed +# in an external log and can't get the normal hooks to reach it. # --------------------------------------------------------------------- def render_capture() -> str: - lines = ['

📥 Capture a conversation

'] + lines = ['

📥 Manual capture (fallback only)

'] lines.append( - '

Paste a chat from Claude Desktop, Claude.ai (web or mobile), ' - 'or any other LLM. It goes through the same pipeline as auto-captured ' - 'interactions: extraction → 3-tier triage → active memory if it carries signal.

' + '
This is not the capture path. ' + 'The sanctioned capture surfaces are Claude Code (Stop hook auto-captures every turn) ' + 'and OpenClaw (plugin auto-captures + injects AtoCore context on every agent turn). ' + 'This form exists only as a last resort for external logs you can\'t get into the normal pipeline.
' + ) + lines.append( + '

If you\'re reaching for this page because you had a chat somewhere AtoCore didn\'t see, ' + 'fix the capture surface instead — don\'t paste. The deliberate scope is Claude Code + OpenClaw.

' ) lines.append('

Your prompt + the assistant\'s response. Project is optional — ' 'the extractor infers it from content.

') diff --git a/tests/test_inject_context_hook.py b/tests/test_inject_context_hook.py new file mode 100644 index 0000000..f6945a0 --- /dev/null +++ b/tests/test_inject_context_hook.py @@ -0,0 +1,198 @@ +"""Tests for deploy/hooks/inject_context.py — Claude Code UserPromptSubmit hook. + +These are process-level tests: we run the actual script with subprocess, +feed it stdin, and check the exit code + stdout shape. The hook must: + - always exit 0 (never block a user prompt) + - emit valid hookSpecificOutput JSON on success + - fail open (empty output) on network errors, bad stdin, kill-switch + - respect the short-prompt filter +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +HOOK = Path(__file__).resolve().parent.parent / "deploy" / "hooks" / "inject_context.py" + + +def _run_hook(stdin_json: dict | str, env_overrides: dict | None = None, timeout: float = 10) -> tuple[int, str, str]: + env = os.environ.copy() + # Force kill switch off unless the test overrides + env.pop("ATOCORE_CONTEXT_DISABLED", None) + if env_overrides: + env.update(env_overrides) + stdin = stdin_json if isinstance(stdin_json, str) else json.dumps(stdin_json) + proc = subprocess.run( + [sys.executable, str(HOOK)], + input=stdin, text=True, + capture_output=True, timeout=timeout, + env=env, + ) + return proc.returncode, proc.stdout, proc.stderr + + +def test_hook_exit_0_on_success_or_failure(): + """Canonical contract: the hook never blocks a prompt. Even with a + bogus URL we must exit 0 with empty stdout (fail-open).""" + code, stdout, stderr = _run_hook( + { + "prompt": "What's the p04-gigabit current status?", + "cwd": "/tmp", + "session_id": "t", + "hook_event_name": "UserPromptSubmit", + }, + env_overrides={"ATOCORE_URL": "http://127.0.0.1:1", # unreachable + "ATOCORE_CONTEXT_TIMEOUT": "1"}, + ) + assert code == 0 + # stdout is empty (fail-open) — no hookSpecificOutput emitted + assert stdout.strip() == "" + assert "atocore unreachable" in stderr or "request failed" in stderr + + +def test_hook_kill_switch(): + code, stdout, stderr = _run_hook( + {"prompt": "hello world is this a thing", "cwd": "", "session_id": "t"}, + env_overrides={"ATOCORE_CONTEXT_DISABLED": "1"}, + ) + assert code == 0 + assert stdout.strip() == "" + + +def test_hook_ignores_short_prompt(): + code, stdout, _ = _run_hook( + {"prompt": "ok", "cwd": "", "session_id": "t"}, + env_overrides={"ATOCORE_URL": "http://127.0.0.1:1"}, + ) + assert code == 0 + # No network call attempted; empty output + assert stdout.strip() == "" + + +def test_hook_ignores_xml_prompt(): + """System/meta prompts starting with '<' should be skipped.""" + code, stdout, _ = _run_hook( + {"prompt": "do something", "cwd": "", "session_id": "t"}, + env_overrides={"ATOCORE_URL": "http://127.0.0.1:1"}, + ) + assert code == 0 + assert stdout.strip() == "" + + +def test_hook_handles_bad_stdin(): + code, stdout, stderr = _run_hook("not-json-at-all") + assert code == 0 + assert stdout.strip() == "" + assert "bad stdin" in stderr + + +def test_hook_handles_empty_stdin(): + code, stdout, _ = _run_hook("") + assert code == 0 + assert stdout.strip() == "" + + +def test_hook_success_shape_with_mock_server(monkeypatch, tmp_path): + """When the API returns a pack, the hook emits valid + hookSpecificOutput JSON wrapping it.""" + # Start a tiny HTTP server on localhost that returns a fake pack + import http.server + import json as _json + import threading + + pack = "Trusted State: foo=bar" + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + self.rfile.read(int(self.headers.get("Content-Length", 0))) + body = _json.dumps({"formatted_context": pack}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a, **kw): + pass + + server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + port = server.server_address[1] + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + try: + code, stdout, stderr = _run_hook( + { + "prompt": "What do we know about p04?", + "cwd": "", + "session_id": "t", + "hook_event_name": "UserPromptSubmit", + }, + env_overrides={ + "ATOCORE_URL": f"http://127.0.0.1:{port}", + "ATOCORE_CONTEXT_TIMEOUT": "5", + }, + timeout=15, + ) + finally: + server.shutdown() + + assert code == 0, stderr + assert stdout.strip(), "expected JSON output with context" + out = json.loads(stdout) + hso = out.get("hookSpecificOutput", {}) + assert hso.get("hookEventName") == "UserPromptSubmit" + assert pack in hso.get("additionalContext", "") + assert "AtoCore-injected context" in hso.get("additionalContext", "") + + +def test_hook_project_inference_from_cwd(monkeypatch): + """The hook should map a known cwd to a project slug and send it in + the /context/build payload.""" + import http.server + import json as _json + import threading + + captured_body: dict = {} + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + n = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(n) + captured_body.update(_json.loads(body.decode())) + out = _json.dumps({"formatted_context": "ok"}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(out))) + self.end_headers() + self.wfile.write(out) + + def log_message(self, *a, **kw): + pass + + server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + port = server.server_address[1] + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + try: + _run_hook( + { + "prompt": "Is this being tested properly", + "cwd": "C:\\Users\\antoi\\ATOCore", + "session_id": "t", + }, + env_overrides={ + "ATOCORE_URL": f"http://127.0.0.1:{port}", + "ATOCORE_CONTEXT_TIMEOUT": "5", + }, + ) + finally: + server.shutdown() + + # Hook should have inferred project="atocore" from the ATOCore cwd + assert captured_body.get("project") == "atocore" + assert captured_body.get("prompt", "").startswith("Is this being tested") diff --git a/tests/test_wiki_pages.py b/tests/test_wiki_pages.py index 7083df1..64b50c6 100644 --- a/tests/test_wiki_pages.py +++ b/tests/test_wiki_pages.py @@ -30,17 +30,23 @@ def _init_all(): init_engineering_schema() -def test_capture_page_renders(tmp_data_dir): +def test_capture_page_renders_as_fallback(tmp_data_dir): _init_all() html = render_capture() - assert "Capture a conversation" in html + # Page is reachable but now labeled as a fallback, not promoted + assert "fallback only" in html + assert "sanctioned capture surfaces are Claude Code" in html + # Form inputs still exist for emergency use assert "cap-prompt" in html assert "cap-response" in html - # Topnav present - assert "topnav" in html - # Source options for mobile/desktop - assert "claude-desktop" in html - assert "claude-mobile" in html + + +def test_capture_not_in_topnav(tmp_data_dir): + """The paste form should NOT appear in topnav — it's not the sanctioned path.""" + _init_all() + html = render_homepage() + assert "/wiki/capture" not in html + assert "📥 Capture" not in html def test_memory_detail_renders(tmp_data_dir): @@ -125,12 +131,11 @@ def test_homepage_has_topnav_and_activity(tmp_data_dir): _init_all() create_memory("knowledge", "homepage test") html = render_homepage() - # Topnav with expected items + # Topnav with expected items (Capture removed — it's not sanctioned capture) assert "🏠 Home" in html assert "📡 Activity" in html - assert "📥 Capture" in html - assert "/wiki/capture" in html assert "/wiki/activity" in html + assert "/wiki/capture" not in html # Activity snippet assert "What the brain is doing" in html