code-context-engine 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_context_engine-0.4.0.dist-info/METADATA +389 -0
- code_context_engine-0.4.0.dist-info/RECORD +63 -0
- code_context_engine-0.4.0.dist-info/WHEEL +5 -0
- code_context_engine-0.4.0.dist-info/entry_points.txt +4 -0
- code_context_engine-0.4.0.dist-info/licenses/LICENSE +21 -0
- code_context_engine-0.4.0.dist-info/top_level.txt +1 -0
- context_engine/__init__.py +3 -0
- context_engine/cli.py +2848 -0
- context_engine/cli_style.py +66 -0
- context_engine/compression/__init__.py +0 -0
- context_engine/compression/compressor.py +144 -0
- context_engine/compression/ollama_client.py +33 -0
- context_engine/compression/output_rules.py +77 -0
- context_engine/compression/prompts.py +9 -0
- context_engine/compression/quality.py +37 -0
- context_engine/config.py +198 -0
- context_engine/dashboard/__init__.py +0 -0
- context_engine/dashboard/_page.py +1548 -0
- context_engine/dashboard/server.py +429 -0
- context_engine/editors.py +265 -0
- context_engine/event_bus.py +24 -0
- context_engine/indexer/__init__.py +0 -0
- context_engine/indexer/chunker.py +147 -0
- context_engine/indexer/embedder.py +154 -0
- context_engine/indexer/embedding_cache.py +168 -0
- context_engine/indexer/git_hooks.py +73 -0
- context_engine/indexer/git_indexer.py +136 -0
- context_engine/indexer/ignorefile.py +96 -0
- context_engine/indexer/manifest.py +78 -0
- context_engine/indexer/pipeline.py +624 -0
- context_engine/indexer/secrets.py +332 -0
- context_engine/indexer/watcher.py +109 -0
- context_engine/integration/__init__.py +0 -0
- context_engine/integration/bootstrap.py +76 -0
- context_engine/integration/git_context.py +132 -0
- context_engine/integration/mcp_server.py +1825 -0
- context_engine/integration/session_capture.py +306 -0
- context_engine/memory/__init__.py +6 -0
- context_engine/memory/compressor.py +344 -0
- context_engine/memory/db.py +922 -0
- context_engine/memory/extractive.py +106 -0
- context_engine/memory/grammar.py +419 -0
- context_engine/memory/hook_installer.py +258 -0
- context_engine/memory/hook_server.py +83 -0
- context_engine/memory/hooks.py +327 -0
- context_engine/memory/migrate.py +268 -0
- context_engine/models.py +96 -0
- context_engine/pricing.py +104 -0
- context_engine/project_commands.py +296 -0
- context_engine/retrieval/__init__.py +0 -0
- context_engine/retrieval/confidence.py +47 -0
- context_engine/retrieval/query_parser.py +105 -0
- context_engine/retrieval/retriever.py +199 -0
- context_engine/serve_http.py +208 -0
- context_engine/services.py +252 -0
- context_engine/storage/__init__.py +0 -0
- context_engine/storage/backend.py +39 -0
- context_engine/storage/fts_store.py +112 -0
- context_engine/storage/graph_store.py +219 -0
- context_engine/storage/local_backend.py +109 -0
- context_engine/storage/remote_backend.py +117 -0
- context_engine/storage/vector_store.py +357 -0
- context_engine/utils.py +72 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Install/uninstall the 5 Claude Code lifecycle hooks for memory capture.
|
|
2
|
+
|
|
3
|
+
Two pieces of state to manage:
|
|
4
|
+
|
|
5
|
+
1. The shell script `cce_hook.sh` lives at `~/.cce/hooks/cce_hook.sh`. It's
|
|
6
|
+
tiny (~20 lines) and reads stdin → POSTs to the local memory hook server.
|
|
7
|
+
|
|
8
|
+
2. The project-level `<project>/.claude/settings.json` gets entries under
|
|
9
|
+
`hooks.<HookName>` pointing to the script. Existing CCE entries (and any
|
|
10
|
+
user-added entries) are preserved.
|
|
11
|
+
|
|
12
|
+
Install is idempotent: re-running adds nothing new if the entries already
|
|
13
|
+
exist. Uninstall removes only the entries we added (matched by command
|
|
14
|
+
substring).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import stat
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_windows() -> bool:
|
|
28
|
+
return sys.platform.startswith("win")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# On Windows, Claude Code hook commands are passed to cmd.exe rather than sh,
|
|
32
|
+
# so we install a .cmd script. On POSIX (macOS/Linux), the original .sh.
|
|
33
|
+
HOOK_SCRIPT_NAME = "cce_hook.cmd" if _is_windows() else "cce_hook.sh"
|
|
34
|
+
HOOK_DIR = Path.home() / ".cce" / "hooks"
|
|
35
|
+
HOOK_PATH = HOOK_DIR / HOOK_SCRIPT_NAME
|
|
36
|
+
|
|
37
|
+
# Marker substring used to identify hooks we own — survives subsequent
|
|
38
|
+
# format/path tweaks as long as the script name stays.
|
|
39
|
+
HOOK_MARKER = "cce_hook"
|
|
40
|
+
|
|
41
|
+
LIFECYCLE_HOOKS = [
|
|
42
|
+
"SessionStart",
|
|
43
|
+
"UserPromptSubmit",
|
|
44
|
+
"PostToolUse",
|
|
45
|
+
"Stop",
|
|
46
|
+
"SessionEnd",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
# Per-hook matcher overrides. Claude Code accepts a regex-like alternation
|
|
50
|
+
# in the matcher field; SessionStart's subtypes are:
|
|
51
|
+
# startup — fresh new conversation
|
|
52
|
+
# clear — `/clear` command was run
|
|
53
|
+
# compact — `/compact` command was run
|
|
54
|
+
# Re-firing SessionStart on `clear`/`compact` is the trigger that re-
|
|
55
|
+
# injects the memory resume after the model's context window is wiped —
|
|
56
|
+
# without it, "/clear" would erase your prior-decisions context.
|
|
57
|
+
# All other hooks default to matcher="" (any).
|
|
58
|
+
HOOK_MATCHERS = {
|
|
59
|
+
"SessionStart": "startup|clear|compact",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_HOOK_SCRIPT_BODY_POSIX = """#!/bin/sh
|
|
63
|
+
# CCE memory hook — installed by `cce init`. Forwards Claude Code hook
|
|
64
|
+
# payloads (JSON on stdin) to the local memory capture server.
|
|
65
|
+
#
|
|
66
|
+
# Failure is silent — capture is best-effort and must never block the
|
|
67
|
+
# user's flow. The hook name is passed as $1 (first argument).
|
|
68
|
+
#
|
|
69
|
+
# Special case: SessionStart's HTTP response is written to stdout so
|
|
70
|
+
# Claude Code injects it into the model's context at session start
|
|
71
|
+
# (this is what prevents last week's decisions from being re-explained).
|
|
72
|
+
# Other hooks discard their response.
|
|
73
|
+
set -u
|
|
74
|
+
|
|
75
|
+
HOOK_NAME="${1:-unknown}"
|
|
76
|
+
PORT_FILE="${HOME}/.cce/projects/$(basename "${PWD}")/serve.port"
|
|
77
|
+
[ -r "${PORT_FILE}" ] || exit 0
|
|
78
|
+
PORT="$(cat "${PORT_FILE}" 2>/dev/null)"
|
|
79
|
+
[ -n "${PORT}" ] || exit 0
|
|
80
|
+
|
|
81
|
+
if [ "${HOOK_NAME}" = "SessionStart" ]; then
|
|
82
|
+
RESPONSE="$(curl -sf -m 2 -X POST -H "Content-Type: application/json" \\
|
|
83
|
+
--data-binary @- "http://127.0.0.1:${PORT}/hooks/${HOOK_NAME}" \\
|
|
84
|
+
2>/dev/null || true)"
|
|
85
|
+
if [ -n "${RESPONSE}" ]; then
|
|
86
|
+
printf "%s\\n" "${RESPONSE}"
|
|
87
|
+
fi
|
|
88
|
+
else
|
|
89
|
+
curl -sf -m 1 -X POST -H "Content-Type: application/json" \\
|
|
90
|
+
--data-binary @- "http://127.0.0.1:${PORT}/hooks/${HOOK_NAME}" \\
|
|
91
|
+
>/dev/null 2>&1 || true
|
|
92
|
+
fi
|
|
93
|
+
exit 0
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
# Windows .cmd equivalent. PowerShell would be more flexible but cmd is
|
|
97
|
+
# always present and avoids the execution-policy gotcha. The same fail-
|
|
98
|
+
# closed semantics: any error → exit 0.
|
|
99
|
+
#
|
|
100
|
+
# SessionStart's response is written to stdout for context injection (see
|
|
101
|
+
# the POSIX comment above); other hooks discard their response.
|
|
102
|
+
_HOOK_SCRIPT_BODY_WIN = """@echo off
|
|
103
|
+
REM CCE memory hook — installed by `cce init`. Forwards Claude Code hook
|
|
104
|
+
REM payloads (JSON on stdin) to the local memory capture server.
|
|
105
|
+
REM Failure is silent (always exit 0) so capture never blocks the user.
|
|
106
|
+
setlocal enabledelayedexpansion
|
|
107
|
+
|
|
108
|
+
set "HOOK_NAME=%~1"
|
|
109
|
+
if "%HOOK_NAME%"=="" set "HOOK_NAME=unknown"
|
|
110
|
+
|
|
111
|
+
for %%I in ("%CD%") do set "PROJECT_NAME=%%~nxI"
|
|
112
|
+
set "PORT_FILE=%USERPROFILE%\\.cce\\projects\\%PROJECT_NAME%\\serve.port"
|
|
113
|
+
if not exist "%PORT_FILE%" exit /b 0
|
|
114
|
+
|
|
115
|
+
set /p PORT=<"%PORT_FILE%"
|
|
116
|
+
if "%PORT%"=="" exit /b 0
|
|
117
|
+
|
|
118
|
+
if /i "%HOOK_NAME%"=="SessionStart" (
|
|
119
|
+
set "TMP_RESP=%TEMP%\\cce_hook_resp_%RANDOM%.txt"
|
|
120
|
+
curl -sf -m 2 -X POST -H "Content-Type: application/json" ^
|
|
121
|
+
--data-binary @- "http://127.0.0.1:%PORT%/hooks/%HOOK_NAME%" ^
|
|
122
|
+
> "%TMP_RESP%" 2>nul
|
|
123
|
+
if exist "%TMP_RESP%" type "%TMP_RESP%"
|
|
124
|
+
if exist "%TMP_RESP%" del "%TMP_RESP%" >nul 2>&1
|
|
125
|
+
) else (
|
|
126
|
+
curl -sf -m 1 -X POST -H "Content-Type: application/json" ^
|
|
127
|
+
--data-binary @- "http://127.0.0.1:%PORT%/hooks/%HOOK_NAME%" >nul 2>&1
|
|
128
|
+
)
|
|
129
|
+
exit /b 0
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _hook_script_body() -> str:
|
|
134
|
+
return _HOOK_SCRIPT_BODY_WIN if _is_windows() else _HOOK_SCRIPT_BODY_POSIX
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _quote_hook_path(path: Path) -> str:
|
|
138
|
+
"""Shell-quote `path` for the host shell. POSIX uses sh; Windows uses cmd.
|
|
139
|
+
|
|
140
|
+
sh accepts single quotes (via shlex.quote), cmd accepts double quotes.
|
|
141
|
+
The two are not interchangeable — POSIX single-quoting on cmd produces
|
|
142
|
+
a literal-quote'd path that cmd won't dequote.
|
|
143
|
+
"""
|
|
144
|
+
s = str(path)
|
|
145
|
+
if _is_windows():
|
|
146
|
+
# Escape any embedded double quotes the cmd way (rare in real paths).
|
|
147
|
+
return '"' + s.replace('"', '""') + '"'
|
|
148
|
+
import shlex
|
|
149
|
+
return shlex.quote(s)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def install_hook_script(target: Path = HOOK_PATH) -> bool:
|
|
153
|
+
"""Write the platform-appropriate hook script to ~/.cce/hooks/.
|
|
154
|
+
Returns True if created/updated.
|
|
155
|
+
"""
|
|
156
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
body = _hook_script_body()
|
|
158
|
+
existing = target.read_text() if target.exists() else None
|
|
159
|
+
if existing == body:
|
|
160
|
+
return False
|
|
161
|
+
target.write_text(body)
|
|
162
|
+
if not _is_windows():
|
|
163
|
+
target.chmod(
|
|
164
|
+
target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
165
|
+
)
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def install_settings(project_dir: Path) -> dict:
|
|
170
|
+
"""Wire all 5 lifecycle hooks into <project>/.claude/settings.json.
|
|
171
|
+
|
|
172
|
+
Idempotent. Preserves any existing user hooks. Returns a summary dict
|
|
173
|
+
with `added` (hook names we wrote) and `skipped` (hook names already
|
|
174
|
+
present).
|
|
175
|
+
"""
|
|
176
|
+
settings_dir = project_dir / ".claude"
|
|
177
|
+
settings_path = settings_dir / "settings.json"
|
|
178
|
+
settings_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
|
|
180
|
+
data: dict = {}
|
|
181
|
+
if settings_path.exists():
|
|
182
|
+
try:
|
|
183
|
+
data = json.loads(settings_path.read_text() or "{}")
|
|
184
|
+
if not isinstance(data, dict):
|
|
185
|
+
data = {}
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
log.warning("Existing settings.json is invalid JSON; rewriting.")
|
|
188
|
+
data = {}
|
|
189
|
+
|
|
190
|
+
hooks = data.setdefault("hooks", {})
|
|
191
|
+
added: list[str] = []
|
|
192
|
+
skipped: list[str] = []
|
|
193
|
+
|
|
194
|
+
for hook_name in LIFECYCLE_HOOKS:
|
|
195
|
+
bucket = hooks.setdefault(hook_name, [])
|
|
196
|
+
if _has_cce_hook(bucket):
|
|
197
|
+
skipped.append(hook_name)
|
|
198
|
+
continue
|
|
199
|
+
# Claude Code passes `command` to `sh -c` on POSIX and to `cmd.exe`
|
|
200
|
+
# on Windows. Both tokenise on whitespace, but they understand
|
|
201
|
+
# *different* quoting: sh wants single quotes (shlex.quote), cmd
|
|
202
|
+
# wants double quotes. POSIX-only quoting on a Windows .cmd path
|
|
203
|
+
# like `C:\Users\Alice Smith\.cce\hooks\cce_hook.cmd` would emit
|
|
204
|
+
# single quotes that cmd doesn't recognise, breaking capture.
|
|
205
|
+
bucket.append({
|
|
206
|
+
"matcher": HOOK_MATCHERS.get(hook_name, ""),
|
|
207
|
+
"hooks": [{
|
|
208
|
+
"type": "command",
|
|
209
|
+
"command": f"{_quote_hook_path(HOOK_PATH)} {hook_name}",
|
|
210
|
+
}],
|
|
211
|
+
})
|
|
212
|
+
added.append(hook_name)
|
|
213
|
+
|
|
214
|
+
settings_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
215
|
+
return {"added": added, "skipped": skipped, "settings_path": str(settings_path)}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def uninstall_settings(project_dir: Path) -> dict:
|
|
219
|
+
"""Remove our 5 hook entries from settings.json. Idempotent."""
|
|
220
|
+
settings_path = project_dir / ".claude" / "settings.json"
|
|
221
|
+
if not settings_path.exists():
|
|
222
|
+
return {"removed": [], "settings_path": str(settings_path)}
|
|
223
|
+
try:
|
|
224
|
+
data = json.loads(settings_path.read_text() or "{}")
|
|
225
|
+
except json.JSONDecodeError:
|
|
226
|
+
return {"removed": [], "settings_path": str(settings_path)}
|
|
227
|
+
if not isinstance(data, dict):
|
|
228
|
+
return {"removed": [], "settings_path": str(settings_path)}
|
|
229
|
+
|
|
230
|
+
hooks = data.get("hooks") or {}
|
|
231
|
+
removed: list[str] = []
|
|
232
|
+
for hook_name in LIFECYCLE_HOOKS:
|
|
233
|
+
bucket = hooks.get(hook_name)
|
|
234
|
+
if not bucket:
|
|
235
|
+
continue
|
|
236
|
+
kept = [entry for entry in bucket if not _has_cce_hook([entry])]
|
|
237
|
+
if len(kept) != len(bucket):
|
|
238
|
+
removed.append(hook_name)
|
|
239
|
+
if kept:
|
|
240
|
+
hooks[hook_name] = kept
|
|
241
|
+
else:
|
|
242
|
+
del hooks[hook_name]
|
|
243
|
+
|
|
244
|
+
if removed:
|
|
245
|
+
if not hooks:
|
|
246
|
+
data.pop("hooks", None)
|
|
247
|
+
settings_path.write_text(json.dumps(data, indent=2) + "\n")
|
|
248
|
+
return {"removed": removed, "settings_path": str(settings_path)}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _has_cce_hook(bucket: list) -> bool:
|
|
252
|
+
"""True if any entry in the bucket runs our hook script."""
|
|
253
|
+
for entry in bucket:
|
|
254
|
+
for h in entry.get("hooks", []) or []:
|
|
255
|
+
cmd = h.get("command", "") or ""
|
|
256
|
+
if HOOK_MARKER in cmd:
|
|
257
|
+
return True
|
|
258
|
+
return False
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Loopback HTTP server for Claude Code hook payloads.
|
|
2
|
+
|
|
3
|
+
Bound to 127.0.0.1 on a random free port. The port is written to
|
|
4
|
+
`<storage_base>/serve.port` so the hook shell script can find it without
|
|
5
|
+
configuration. No auth — the listener is loopback-only.
|
|
6
|
+
|
|
7
|
+
Started as a background asyncio task from `_run_serve` (the MCP server
|
|
8
|
+
process). Stopped gracefully on shutdown.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import socket
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from aiohttp import web
|
|
17
|
+
|
|
18
|
+
from context_engine.memory import db as memory_db
|
|
19
|
+
from context_engine.memory.hooks import add_routes
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_free_port() -> int:
|
|
25
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
26
|
+
s.bind(("127.0.0.1", 0))
|
|
27
|
+
return s.getsockname()[1]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def start_hook_server(
|
|
31
|
+
*,
|
|
32
|
+
storage_base: Path,
|
|
33
|
+
project_name: str,
|
|
34
|
+
) -> tuple[web.AppRunner, int]:
|
|
35
|
+
"""Spin up the hook HTTP listener. Returns (runner, port).
|
|
36
|
+
|
|
37
|
+
Caller is responsible for `await runner.cleanup()` on shutdown.
|
|
38
|
+
"""
|
|
39
|
+
db_path = memory_db.memory_db_path(storage_base)
|
|
40
|
+
conn = memory_db.connect(db_path)
|
|
41
|
+
|
|
42
|
+
app = web.Application()
|
|
43
|
+
app["memory_db"] = conn
|
|
44
|
+
app["project_name"] = project_name
|
|
45
|
+
add_routes(app)
|
|
46
|
+
|
|
47
|
+
async def _close_db(app):
|
|
48
|
+
try:
|
|
49
|
+
app["memory_db"].close()
|
|
50
|
+
except Exception:
|
|
51
|
+
log.exception("memory_db close failed")
|
|
52
|
+
|
|
53
|
+
app.on_cleanup.append(_close_db)
|
|
54
|
+
|
|
55
|
+
runner = web.AppRunner(app)
|
|
56
|
+
await runner.setup()
|
|
57
|
+
|
|
58
|
+
port = _find_free_port()
|
|
59
|
+
site = web.TCPSite(runner, host="127.0.0.1", port=port)
|
|
60
|
+
await site.start()
|
|
61
|
+
|
|
62
|
+
# Authoritative port file lives in the project's storage_base.
|
|
63
|
+
port_file = Path(storage_base) / "serve.port"
|
|
64
|
+
port_file.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
port_file.write_text(str(port))
|
|
66
|
+
|
|
67
|
+
# Stable rendezvous file at the *default* storage location. The hook
|
|
68
|
+
# shell script always looks here (`${HOME}/.cce/projects/<name>/serve.port`)
|
|
69
|
+
# because it has no way to read the user's config.yaml. When storage_path
|
|
70
|
+
# is customised, this is the only way capture stays wired up.
|
|
71
|
+
default_rendezvous = (
|
|
72
|
+
Path.home() / ".cce" / "projects" / project_name / "serve.port"
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
if default_rendezvous.resolve() != port_file.resolve():
|
|
76
|
+
default_rendezvous.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
default_rendezvous.write_text(str(port))
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
# Non-fatal — capture still works for users with default storage.
|
|
80
|
+
log.warning("rendezvous port file write failed: %s", exc)
|
|
81
|
+
|
|
82
|
+
log.info("Memory hook server listening on 127.0.0.1:%d", port)
|
|
83
|
+
return runner, port
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""HTTP handlers backing the 5 Claude Code lifecycle hooks.
|
|
2
|
+
|
|
3
|
+
Endpoints (all loopback-only, no auth):
|
|
4
|
+
POST /hooks/SessionStart -> insert sessions row
|
|
5
|
+
POST /hooks/UserPromptSubmit -> insert prompts row, enqueue prev-turn compress
|
|
6
|
+
POST /hooks/PostToolUse -> insert tool_event + tool_event_payload
|
|
7
|
+
POST /hooks/Stop -> mark turn complete, enqueue compress
|
|
8
|
+
POST /hooks/SessionEnd -> mark session complete, enqueue rollup
|
|
9
|
+
|
|
10
|
+
Hooks are best-effort: every write is wrapped so a payload-shape error never
|
|
11
|
+
500s back to the hook script (which is `set -e` shell). On error we log + return
|
|
12
|
+
202 so the user's flow is never blocked. The dashboard surfaces error counts.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import sqlite3
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from aiohttp import web
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now_epoch() -> int:
|
|
28
|
+
return int(time.time())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _now_iso(epoch: int | None = None) -> str:
|
|
32
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(epoch or _now_epoch()))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _read_json(request: web.Request) -> dict:
|
|
36
|
+
try:
|
|
37
|
+
return await request.json()
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
log.warning("Hook payload not JSON: %s", exc)
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _conn(request: web.Request) -> sqlite3.Connection:
|
|
44
|
+
return request.app["memory_db"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_RESUME_RECENT_DECISIONS = 5
|
|
48
|
+
_RESUME_DECISION_REASON_CHARS = 200
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_session_resume(conn: sqlite3.Connection, project: str) -> str:
|
|
52
|
+
"""Compose a short text block summarising recent state for the model.
|
|
53
|
+
|
|
54
|
+
Returned as plain text and printed by the hook shell script to stdout.
|
|
55
|
+
Claude Code injects SessionStart hook stdout into the model's context at
|
|
56
|
+
conversation start — so this is the mechanism that prevents "decisions
|
|
57
|
+
you made last week have to be re-explained today." Empty string for
|
|
58
|
+
a brand-new project so there's no awkward header on the first session.
|
|
59
|
+
"""
|
|
60
|
+
parts: list[str] = []
|
|
61
|
+
|
|
62
|
+
last_rollup = conn.execute(
|
|
63
|
+
"SELECT id, rollup_summary, ended_at "
|
|
64
|
+
"FROM sessions "
|
|
65
|
+
"WHERE rollup_summary IS NOT NULL AND rollup_summary != '' "
|
|
66
|
+
"ORDER BY started_at_epoch DESC LIMIT 1"
|
|
67
|
+
).fetchone()
|
|
68
|
+
|
|
69
|
+
decisions = list(conn.execute(
|
|
70
|
+
"SELECT decision, reason, source, session_id, created_at "
|
|
71
|
+
"FROM decisions "
|
|
72
|
+
"ORDER BY created_at_epoch DESC LIMIT ?",
|
|
73
|
+
(_RESUME_RECENT_DECISIONS,),
|
|
74
|
+
))
|
|
75
|
+
|
|
76
|
+
if not last_rollup and not decisions:
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
parts.append(f"## CCE memory · resuming {project}")
|
|
80
|
+
# Stored values went through grammar.compress on the write side; expand
|
|
81
|
+
# before display so the resume reads as natural prose.
|
|
82
|
+
from context_engine.memory.grammar import expand as _grammar_expand
|
|
83
|
+
|
|
84
|
+
if last_rollup:
|
|
85
|
+
when = last_rollup["ended_at"] or "in progress"
|
|
86
|
+
parts.append("")
|
|
87
|
+
parts.append(f"**Previous session** ({when}):")
|
|
88
|
+
rollup = _grammar_expand((last_rollup["rollup_summary"] or "").strip())
|
|
89
|
+
for line in rollup.split("\n"):
|
|
90
|
+
line = line.strip()
|
|
91
|
+
if line:
|
|
92
|
+
parts.append(f" {line}")
|
|
93
|
+
if decisions:
|
|
94
|
+
parts.append("")
|
|
95
|
+
parts.append("**Recent decisions** (most-recent first):")
|
|
96
|
+
for d in decisions:
|
|
97
|
+
decision = _grammar_expand((d["decision"] or "").strip())
|
|
98
|
+
# Truncate before expand so the cap operates on stored bytes,
|
|
99
|
+
# not on post-expand bytes — otherwise two reasons that stored
|
|
100
|
+
# equal length display unequal length depending on how many
|
|
101
|
+
# abbreviations expand.
|
|
102
|
+
stored_reason = (d["reason"] or "").strip()
|
|
103
|
+
if len(stored_reason) > _RESUME_DECISION_REASON_CHARS:
|
|
104
|
+
stored_reason = stored_reason[:_RESUME_DECISION_REASON_CHARS] + "…"
|
|
105
|
+
reason = _grammar_expand(stored_reason)
|
|
106
|
+
tag = ""
|
|
107
|
+
if d["source"] != "manual":
|
|
108
|
+
tag = f" _[{d['source']}]_"
|
|
109
|
+
sid_hint = ""
|
|
110
|
+
if d["session_id"]:
|
|
111
|
+
sid_hint = f' (session: `{d["session_id"]}`)'
|
|
112
|
+
parts.append(f" - {decision} — {reason}{tag}{sid_hint}")
|
|
113
|
+
parts.append("")
|
|
114
|
+
parts.append(
|
|
115
|
+
"Call `session_recall(\"<topic>\")` to find more, or "
|
|
116
|
+
"`session_timeline(\"<sid>\")` to drill into a session."
|
|
117
|
+
)
|
|
118
|
+
return "\n".join(parts)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def handle_session_start(request: web.Request) -> web.Response:
|
|
122
|
+
"""Insert the new session row and return resume context as plain text.
|
|
123
|
+
|
|
124
|
+
The body of the response is captured by the hook shell script and
|
|
125
|
+
printed to stdout, which Claude Code injects into the model's context
|
|
126
|
+
at session start. That's how prior-week decisions surface without a
|
|
127
|
+
tool call.
|
|
128
|
+
"""
|
|
129
|
+
data = await _read_json(request)
|
|
130
|
+
session_id = data.get("session_id") or data.get("sessionId")
|
|
131
|
+
if not session_id:
|
|
132
|
+
return web.Response(text="", status=400)
|
|
133
|
+
project = data.get("project") or request.app.get("project_name", "")
|
|
134
|
+
started_epoch = int(data.get("started_at") or _now_epoch())
|
|
135
|
+
|
|
136
|
+
conn = _conn(request)
|
|
137
|
+
try:
|
|
138
|
+
conn.execute(
|
|
139
|
+
"INSERT OR IGNORE INTO sessions "
|
|
140
|
+
"(id, project, started_at_epoch, started_at, status) "
|
|
141
|
+
"VALUES (?, ?, ?, ?, 'active')",
|
|
142
|
+
(session_id, project, started_epoch, _now_iso(started_epoch)),
|
|
143
|
+
)
|
|
144
|
+
conn.commit()
|
|
145
|
+
except Exception:
|
|
146
|
+
log.exception("SessionStart insert failed")
|
|
147
|
+
return web.Response(text="", status=202)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
resume = build_session_resume(conn, project)
|
|
151
|
+
except Exception:
|
|
152
|
+
log.exception("SessionStart resume build failed")
|
|
153
|
+
resume = ""
|
|
154
|
+
return web.Response(text=resume, content_type="text/plain")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def handle_user_prompt_submit(request: web.Request) -> web.Response:
|
|
158
|
+
data = await _read_json(request)
|
|
159
|
+
session_id = data.get("session_id")
|
|
160
|
+
prompt_text = data.get("prompt_text") or data.get("prompt") or ""
|
|
161
|
+
prompt_number = data.get("prompt_number")
|
|
162
|
+
if not session_id:
|
|
163
|
+
return web.json_response({"error": "session_id required"}, status=400)
|
|
164
|
+
|
|
165
|
+
conn = _conn(request)
|
|
166
|
+
try:
|
|
167
|
+
if prompt_number is None:
|
|
168
|
+
row = conn.execute(
|
|
169
|
+
"SELECT COALESCE(MAX(prompt_number), 0) + 1 AS next "
|
|
170
|
+
"FROM prompts WHERE session_id = ?",
|
|
171
|
+
(session_id,),
|
|
172
|
+
).fetchone()
|
|
173
|
+
prompt_number = int(row["next"])
|
|
174
|
+
|
|
175
|
+
epoch = _now_epoch()
|
|
176
|
+
conn.execute(
|
|
177
|
+
"INSERT OR IGNORE INTO prompts "
|
|
178
|
+
"(session_id, prompt_number, prompt_text, created_at_epoch, created_at) "
|
|
179
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
180
|
+
(session_id, int(prompt_number), str(prompt_text), epoch, _now_iso(epoch)),
|
|
181
|
+
)
|
|
182
|
+
conn.execute(
|
|
183
|
+
"UPDATE sessions SET prompt_count = prompt_count + 1 WHERE id = ?",
|
|
184
|
+
(session_id,),
|
|
185
|
+
)
|
|
186
|
+
# Enqueue previous turn for compression. The session may have N-1
|
|
187
|
+
# prompts now; compress turn N-1.
|
|
188
|
+
if int(prompt_number) > 1:
|
|
189
|
+
_enqueue_compression(
|
|
190
|
+
conn, kind="turn",
|
|
191
|
+
session_id=session_id,
|
|
192
|
+
prompt_number=int(prompt_number) - 1,
|
|
193
|
+
)
|
|
194
|
+
conn.commit()
|
|
195
|
+
except Exception:
|
|
196
|
+
log.exception("UserPromptSubmit insert failed")
|
|
197
|
+
return web.json_response({"ok": False}, status=202)
|
|
198
|
+
return web.json_response({"ok": True, "prompt_number": prompt_number})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def handle_post_tool_use(request: web.Request) -> web.Response:
|
|
202
|
+
data = await _read_json(request)
|
|
203
|
+
session_id = data.get("session_id")
|
|
204
|
+
if not session_id:
|
|
205
|
+
return web.json_response({"error": "session_id required"}, status=400)
|
|
206
|
+
|
|
207
|
+
tool_name = data.get("tool_name", "unknown")
|
|
208
|
+
tool_input = data.get("tool_input") or data.get("tool_input_json") or {}
|
|
209
|
+
tool_output = data.get("tool_output") or data.get("tool_output_json") or ""
|
|
210
|
+
prompt_number = data.get("prompt_number")
|
|
211
|
+
|
|
212
|
+
raw_input = tool_input if isinstance(tool_input, str) else json.dumps(tool_input)
|
|
213
|
+
raw_output = tool_output if isinstance(tool_output, str) else json.dumps(tool_output)
|
|
214
|
+
size = len(raw_input) + len(raw_output)
|
|
215
|
+
|
|
216
|
+
conn = _conn(request)
|
|
217
|
+
try:
|
|
218
|
+
if prompt_number is None:
|
|
219
|
+
row = conn.execute(
|
|
220
|
+
"SELECT COALESCE(MAX(prompt_number), 0) AS cur FROM prompts "
|
|
221
|
+
"WHERE session_id = ?",
|
|
222
|
+
(session_id,),
|
|
223
|
+
).fetchone()
|
|
224
|
+
prompt_number = int(row["cur"]) or 0
|
|
225
|
+
|
|
226
|
+
cur = conn.execute(
|
|
227
|
+
"INSERT INTO tool_event_payloads (raw_input, raw_output, size_bytes) "
|
|
228
|
+
"VALUES (?, ?, ?)",
|
|
229
|
+
(raw_input, raw_output, size),
|
|
230
|
+
)
|
|
231
|
+
payload_id = cur.lastrowid
|
|
232
|
+
epoch = _now_epoch()
|
|
233
|
+
conn.execute(
|
|
234
|
+
"INSERT INTO tool_events "
|
|
235
|
+
"(session_id, prompt_number, tool_name, payload_id, created_at_epoch, created_at) "
|
|
236
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
237
|
+
(session_id, int(prompt_number), tool_name, payload_id, epoch, _now_iso(epoch)),
|
|
238
|
+
)
|
|
239
|
+
conn.commit()
|
|
240
|
+
except Exception:
|
|
241
|
+
log.exception("PostToolUse insert failed")
|
|
242
|
+
return web.json_response({"ok": False}, status=202)
|
|
243
|
+
return web.json_response({"ok": True})
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def handle_stop(request: web.Request) -> web.Response:
|
|
247
|
+
data = await _read_json(request)
|
|
248
|
+
session_id = data.get("session_id")
|
|
249
|
+
prompt_number = data.get("prompt_number")
|
|
250
|
+
if not session_id:
|
|
251
|
+
return web.json_response({"error": "session_id required"}, status=400)
|
|
252
|
+
|
|
253
|
+
conn = _conn(request)
|
|
254
|
+
try:
|
|
255
|
+
if prompt_number is None:
|
|
256
|
+
row = conn.execute(
|
|
257
|
+
"SELECT COALESCE(MAX(prompt_number), 0) AS cur FROM prompts "
|
|
258
|
+
"WHERE session_id = ?",
|
|
259
|
+
(session_id,),
|
|
260
|
+
).fetchone()
|
|
261
|
+
prompt_number = int(row["cur"]) or 0
|
|
262
|
+
if int(prompt_number) > 0:
|
|
263
|
+
_enqueue_compression(
|
|
264
|
+
conn, kind="turn",
|
|
265
|
+
session_id=session_id, prompt_number=int(prompt_number),
|
|
266
|
+
)
|
|
267
|
+
conn.commit()
|
|
268
|
+
except Exception:
|
|
269
|
+
log.exception("Stop enqueue failed")
|
|
270
|
+
return web.json_response({"ok": False}, status=202)
|
|
271
|
+
return web.json_response({"ok": True})
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def handle_session_end(request: web.Request) -> web.Response:
|
|
275
|
+
data = await _read_json(request)
|
|
276
|
+
session_id = data.get("session_id")
|
|
277
|
+
exit_reason = data.get("exit_reason") or "normal"
|
|
278
|
+
if not session_id:
|
|
279
|
+
return web.json_response({"error": "session_id required"}, status=400)
|
|
280
|
+
|
|
281
|
+
conn = _conn(request)
|
|
282
|
+
try:
|
|
283
|
+
epoch = _now_epoch()
|
|
284
|
+
conn.execute(
|
|
285
|
+
"UPDATE sessions SET status = 'completed', exit_reason = ?, "
|
|
286
|
+
"ended_at_epoch = ?, ended_at = ? WHERE id = ?",
|
|
287
|
+
(exit_reason, epoch, _now_iso(epoch), session_id),
|
|
288
|
+
)
|
|
289
|
+
_enqueue_compression(
|
|
290
|
+
conn, kind="session_rollup",
|
|
291
|
+
session_id=session_id, prompt_number=None,
|
|
292
|
+
)
|
|
293
|
+
conn.commit()
|
|
294
|
+
except Exception:
|
|
295
|
+
log.exception("SessionEnd update failed")
|
|
296
|
+
return web.json_response({"ok": False}, status=202)
|
|
297
|
+
return web.json_response({"ok": True})
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _enqueue_compression(
|
|
301
|
+
conn: sqlite3.Connection,
|
|
302
|
+
*,
|
|
303
|
+
kind: str,
|
|
304
|
+
session_id: str,
|
|
305
|
+
prompt_number: int | None,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Add a (kind, session_id, prompt_number) row to pending_compressions.
|
|
308
|
+
|
|
309
|
+
UNIQUE(kind, session_id, prompt_number) guards against double-enqueue when
|
|
310
|
+
a prompt fires both Stop *and* the next UserPromptSubmit's "compress prev"
|
|
311
|
+
trigger in quick succession.
|
|
312
|
+
"""
|
|
313
|
+
conn.execute(
|
|
314
|
+
"INSERT OR IGNORE INTO pending_compressions "
|
|
315
|
+
"(kind, session_id, prompt_number, enqueued_at_epoch) "
|
|
316
|
+
"VALUES (?, ?, ?, ?)",
|
|
317
|
+
(kind, session_id, prompt_number, _now_epoch()),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def add_routes(app: web.Application) -> None:
|
|
322
|
+
"""Attach the 5 hook routes to an existing aiohttp app."""
|
|
323
|
+
app.router.add_post("/hooks/SessionStart", handle_session_start)
|
|
324
|
+
app.router.add_post("/hooks/UserPromptSubmit", handle_user_prompt_submit)
|
|
325
|
+
app.router.add_post("/hooks/PostToolUse", handle_post_tool_use)
|
|
326
|
+
app.router.add_post("/hooks/Stop", handle_stop)
|
|
327
|
+
app.router.add_post("/hooks/SessionEnd", handle_session_end)
|