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.
Files changed (63) hide show
  1. code_context_engine-0.4.0.dist-info/METADATA +389 -0
  2. code_context_engine-0.4.0.dist-info/RECORD +63 -0
  3. code_context_engine-0.4.0.dist-info/WHEEL +5 -0
  4. code_context_engine-0.4.0.dist-info/entry_points.txt +4 -0
  5. code_context_engine-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. code_context_engine-0.4.0.dist-info/top_level.txt +1 -0
  7. context_engine/__init__.py +3 -0
  8. context_engine/cli.py +2848 -0
  9. context_engine/cli_style.py +66 -0
  10. context_engine/compression/__init__.py +0 -0
  11. context_engine/compression/compressor.py +144 -0
  12. context_engine/compression/ollama_client.py +33 -0
  13. context_engine/compression/output_rules.py +77 -0
  14. context_engine/compression/prompts.py +9 -0
  15. context_engine/compression/quality.py +37 -0
  16. context_engine/config.py +198 -0
  17. context_engine/dashboard/__init__.py +0 -0
  18. context_engine/dashboard/_page.py +1548 -0
  19. context_engine/dashboard/server.py +429 -0
  20. context_engine/editors.py +265 -0
  21. context_engine/event_bus.py +24 -0
  22. context_engine/indexer/__init__.py +0 -0
  23. context_engine/indexer/chunker.py +147 -0
  24. context_engine/indexer/embedder.py +154 -0
  25. context_engine/indexer/embedding_cache.py +168 -0
  26. context_engine/indexer/git_hooks.py +73 -0
  27. context_engine/indexer/git_indexer.py +136 -0
  28. context_engine/indexer/ignorefile.py +96 -0
  29. context_engine/indexer/manifest.py +78 -0
  30. context_engine/indexer/pipeline.py +624 -0
  31. context_engine/indexer/secrets.py +332 -0
  32. context_engine/indexer/watcher.py +109 -0
  33. context_engine/integration/__init__.py +0 -0
  34. context_engine/integration/bootstrap.py +76 -0
  35. context_engine/integration/git_context.py +132 -0
  36. context_engine/integration/mcp_server.py +1825 -0
  37. context_engine/integration/session_capture.py +306 -0
  38. context_engine/memory/__init__.py +6 -0
  39. context_engine/memory/compressor.py +344 -0
  40. context_engine/memory/db.py +922 -0
  41. context_engine/memory/extractive.py +106 -0
  42. context_engine/memory/grammar.py +419 -0
  43. context_engine/memory/hook_installer.py +258 -0
  44. context_engine/memory/hook_server.py +83 -0
  45. context_engine/memory/hooks.py +327 -0
  46. context_engine/memory/migrate.py +268 -0
  47. context_engine/models.py +96 -0
  48. context_engine/pricing.py +104 -0
  49. context_engine/project_commands.py +296 -0
  50. context_engine/retrieval/__init__.py +0 -0
  51. context_engine/retrieval/confidence.py +47 -0
  52. context_engine/retrieval/query_parser.py +105 -0
  53. context_engine/retrieval/retriever.py +199 -0
  54. context_engine/serve_http.py +208 -0
  55. context_engine/services.py +252 -0
  56. context_engine/storage/__init__.py +0 -0
  57. context_engine/storage/backend.py +39 -0
  58. context_engine/storage/fts_store.py +112 -0
  59. context_engine/storage/graph_store.py +219 -0
  60. context_engine/storage/local_backend.py +109 -0
  61. context_engine/storage/remote_backend.py +117 -0
  62. context_engine/storage/vector_store.py +357 -0
  63. 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)