crprotocol 2.0.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 (153) hide show
  1. crp/__init__.py +126 -0
  2. crp/__main__.py +8 -0
  3. crp/_typing.py +27 -0
  4. crp/_version.py +5 -0
  5. crp/adapters.py +31 -0
  6. crp/advanced/__init__.py +40 -0
  7. crp/advanced/auto_ingest.py +400 -0
  8. crp/advanced/cqs.py +235 -0
  9. crp/advanced/cross_window.py +477 -0
  10. crp/advanced/curator.py +265 -0
  11. crp/advanced/feedback.py +146 -0
  12. crp/advanced/hierarchical.py +211 -0
  13. crp/advanced/meta_learning.py +401 -0
  14. crp/advanced/parallel.py +98 -0
  15. crp/advanced/review_cycle.py +329 -0
  16. crp/advanced/scale_mode.py +129 -0
  17. crp/advanced/source_grounding.py +207 -0
  18. crp/ckf/__init__.py +35 -0
  19. crp/ckf/community.py +377 -0
  20. crp/ckf/fabric.py +445 -0
  21. crp/ckf/gc.py +175 -0
  22. crp/ckf/graph_walk.py +87 -0
  23. crp/ckf/merge.py +133 -0
  24. crp/ckf/pattern_query.py +122 -0
  25. crp/ckf/pubsub.py +128 -0
  26. crp/ckf/semantic.py +207 -0
  27. crp/cli/__init__.py +7 -0
  28. crp/cli/main.py +329 -0
  29. crp/cli/sidecar.py +929 -0
  30. crp/cli/startup.py +272 -0
  31. crp/continuation/__init__.py +103 -0
  32. crp/continuation/completion.py +348 -0
  33. crp/continuation/degradation.py +157 -0
  34. crp/continuation/document_map.py +160 -0
  35. crp/continuation/flow.py +109 -0
  36. crp/continuation/gap.py +419 -0
  37. crp/continuation/manager.py +484 -0
  38. crp/continuation/quality_monitor.py +179 -0
  39. crp/continuation/stitch.py +419 -0
  40. crp/continuation/trigger.py +142 -0
  41. crp/continuation/voice.py +157 -0
  42. crp/core/__init__.py +69 -0
  43. crp/core/batch.py +77 -0
  44. crp/core/circuit_breaker.py +116 -0
  45. crp/core/config.py +377 -0
  46. crp/core/context_tools.py +540 -0
  47. crp/core/dispatch_router.py +3977 -0
  48. crp/core/errors.py +128 -0
  49. crp/core/extraction_facade.py +384 -0
  50. crp/core/facilitator.py +713 -0
  51. crp/core/idempotency.py +215 -0
  52. crp/core/orchestrator.py +1435 -0
  53. crp/core/relay_strategies.py +613 -0
  54. crp/core/security_manager.py +140 -0
  55. crp/core/session.py +134 -0
  56. crp/core/task_intent.py +36 -0
  57. crp/core/window.py +363 -0
  58. crp/envelope/__init__.py +30 -0
  59. crp/envelope/builder.py +288 -0
  60. crp/envelope/decomposer.py +236 -0
  61. crp/envelope/formatter.py +168 -0
  62. crp/envelope/packer.py +211 -0
  63. crp/envelope/reranker.py +209 -0
  64. crp/envelope/scoring.py +310 -0
  65. crp/extraction/__init__.py +45 -0
  66. crp/extraction/complexity.py +96 -0
  67. crp/extraction/contradiction.py +132 -0
  68. crp/extraction/pipeline.py +360 -0
  69. crp/extraction/quality_gate.py +237 -0
  70. crp/extraction/stage1_regex.py +173 -0
  71. crp/extraction/stage2_statistical.py +244 -0
  72. crp/extraction/stage3_gliner.py +210 -0
  73. crp/extraction/stage4_uie.py +183 -0
  74. crp/extraction/stage5_discourse.py +175 -0
  75. crp/extraction/stage6_llm.py +178 -0
  76. crp/extraction/structured_output.py +219 -0
  77. crp/extraction/types.py +299 -0
  78. crp/license_guard.py +722 -0
  79. crp/observability/__init__.py +30 -0
  80. crp/observability/audit.py +118 -0
  81. crp/observability/events.py +233 -0
  82. crp/observability/metrics.py +264 -0
  83. crp/observability/quality.py +135 -0
  84. crp/observability/structured_logging.py +81 -0
  85. crp/observability/telemetry.py +117 -0
  86. crp/provenance/__init__.py +314 -0
  87. crp/provenance/_embeddings.py +97 -0
  88. crp/provenance/_types.py +378 -0
  89. crp/provenance/attribution_scorer.py +252 -0
  90. crp/provenance/claim_detector.py +229 -0
  91. crp/provenance/contradiction_detector.py +243 -0
  92. crp/provenance/distortion_detector.py +397 -0
  93. crp/provenance/entailment_verifier.py +358 -0
  94. crp/provenance/fabrication_detector.py +203 -0
  95. crp/provenance/hallucination_scorer.py +320 -0
  96. crp/provenance/omission_analyzer.py +106 -0
  97. crp/provenance/provenance_chain.py +205 -0
  98. crp/provenance/report_generator.py +440 -0
  99. crp/providers/__init__.py +43 -0
  100. crp/providers/anthropic.py +270 -0
  101. crp/providers/base.py +135 -0
  102. crp/providers/custom.py +63 -0
  103. crp/providers/diagnostic.py +251 -0
  104. crp/providers/llamacpp.py +224 -0
  105. crp/providers/manager.py +139 -0
  106. crp/providers/ollama.py +243 -0
  107. crp/providers/openai.py +628 -0
  108. crp/providers/tokenizers.py +48 -0
  109. crp/py.typed +0 -0
  110. crp/resources/__init__.py +53 -0
  111. crp/resources/adaptive_allocator.py +525 -0
  112. crp/resources/cost_model.py +388 -0
  113. crp/resources/overhead_manager.py +217 -0
  114. crp/resources/resource_manager.py +262 -0
  115. crp/schemas/__init__.py +20 -0
  116. crp/schemas/cost-estimate.json +33 -0
  117. crp/schemas/crp-error.json +43 -0
  118. crp/schemas/envelope-preview.json +40 -0
  119. crp/schemas/persisted-state-header.json +27 -0
  120. crp/schemas/quality-report.json +94 -0
  121. crp/schemas/session-handle.json +33 -0
  122. crp/schemas/session-status.json +57 -0
  123. crp/schemas/stream-event.json +18 -0
  124. crp/schemas/task-intent.json +42 -0
  125. crp/security/__init__.py +93 -0
  126. crp/security/audit_trail.py +392 -0
  127. crp/security/binding.py +192 -0
  128. crp/security/compliance.py +813 -0
  129. crp/security/consent.py +593 -0
  130. crp/security/embedding_defense.py +161 -0
  131. crp/security/encryption.py +202 -0
  132. crp/security/injection.py +335 -0
  133. crp/security/integrity.py +267 -0
  134. crp/security/privacy.py +662 -0
  135. crp/security/quarantine.py +249 -0
  136. crp/security/rbac.py +221 -0
  137. crp/security/validation.py +164 -0
  138. crp/state/__init__.py +31 -0
  139. crp/state/cold_storage.py +258 -0
  140. crp/state/compaction.py +263 -0
  141. crp/state/critical_state.py +104 -0
  142. crp/state/event_log.py +313 -0
  143. crp/state/fact.py +189 -0
  144. crp/state/serialization.py +189 -0
  145. crp/state/session_cleanup.py +77 -0
  146. crp/state/snapshot.py +290 -0
  147. crp/state/warm_store.py +346 -0
  148. crprotocol-2.0.0.dist-info/METADATA +1295 -0
  149. crprotocol-2.0.0.dist-info/RECORD +153 -0
  150. crprotocol-2.0.0.dist-info/WHEEL +4 -0
  151. crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
  152. crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
  153. crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
crp/cli/sidecar.py ADDED
@@ -0,0 +1,929 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """CRP HTTP sidecar — lightweight REST API for inter-process context sharing (§9.3).
4
+
5
+ Architecture
6
+ ~~~~~~~~~~~~
7
+ The sidecar exposes CRP sessions over HTTP, enabling:
8
+
9
+ 1. **Inter-LLM fact sharing** — two applications using different LLMs can
10
+ share extracted knowledge. Application A (Claude) extracts facts,
11
+ Application B (GPT-4) receives them via ``/facts/share`` — both benefit
12
+ from the other's knowledge without direct LLM-to-LLM communication.
13
+
14
+ 2. **Full protocol surface** — every CRP dispatch variant (basic, tools,
15
+ reflexive, progressive, stream-augmented, agentic) is available over
16
+ HTTP. Feedback loops, cost estimation, and provider registration are
17
+ also exposed.
18
+
19
+ 3. **Language-agnostic integration** — any language/framework can interact
20
+ with CRP via HTTP (TypeScript frontend, Rust service, Python backend).
21
+
22
+ 4. **Dashboard & monitoring** — query session status, inspect facts,
23
+ preview envelopes, and track event history.
24
+
25
+ Endpoints
26
+ ~~~~~~~~~
27
+ Session lifecycle::
28
+
29
+ POST /sessions Create a new CRP session
30
+ GET /sessions List active sessions
31
+ GET /sessions/:id/status Session status / metrics
32
+ POST /sessions/:id/close Close session
33
+
34
+ Dispatch (all 6 variants)::
35
+
36
+ POST /sessions/:id/dispatch Basic dispatch
37
+ POST /sessions/:id/dispatch/tools Tool-mediated dispatch
38
+ POST /sessions/:id/dispatch/reflexive Reflexive (verify) dispatch
39
+ POST /sessions/:id/dispatch/progressive Progressive dispatch
40
+ POST /sessions/:id/dispatch/stream-augmented Stream-augmented dispatch
41
+ POST /sessions/:id/dispatch/agentic Agentic dispatch
42
+
43
+ Knowledge::
44
+
45
+ POST /sessions/:id/ingest Ingest raw text
46
+ GET /sessions/:id/facts Query extracted facts
47
+ POST /sessions/:id/facts/share Share facts TO another session
48
+ POST /sessions/:id/facts/feedback Boost / penalize / reject facts
49
+ GET /sessions/:id/envelope Preview envelope
50
+
51
+ Provider::
52
+
53
+ POST /sessions/:id/providers Register fallback provider
54
+
55
+ Admin::
56
+
57
+ GET /health Health check
58
+ POST /sessions/:id/estimate Cost estimation
59
+
60
+ Security
61
+ ~~~~~~~~
62
+ - **Off by default** — ``crp serve`` must be explicitly invoked; the sidecar
63
+ is never started automatically by the library.
64
+ - Binds to ``127.0.0.1`` (loopback only) by default.
65
+ - Optional bearer-token authentication (``--auth-token``).
66
+ - Per-session RBAC enforced through the orchestrator's existing security layer.
67
+ - Request body size capped at 10 MB (configurable).
68
+ - Rate limiting: configurable per-IP burst window.
69
+ - Session ownership: sessions are bound to the token hash that created them.
70
+ - ``--bind-all`` requires explicit ``--auth-token`` or ``--allow-unauthenticated``.
71
+ - No HTTPS built-in — deploy behind a TLS-terminating reverse proxy for
72
+ production use (nginx, Caddy, etc.).
73
+ """
74
+
75
+ from __future__ import annotations
76
+
77
+ import hashlib
78
+ import json
79
+ import logging
80
+ import secrets
81
+ import threading
82
+ import time
83
+ import uuid
84
+ from collections import defaultdict, deque
85
+ from http.server import HTTPServer, BaseHTTPRequestHandler
86
+ from typing import Any
87
+ from urllib.parse import urlparse, parse_qs
88
+
89
+ logger = logging.getLogger("crp.sidecar")
90
+
91
+
92
+ def _sanitize_error(exc: Exception) -> str:
93
+ """Sanitize internal error details before sending to client (§audit M11).
94
+
95
+ Strips stack traces, file paths, and internal module names.
96
+ Returns a generic message for unexpected errors that might leak internals.
97
+ """
98
+ from crp.core.errors import CRPError
99
+ if isinstance(exc, (ValueError, TypeError, KeyError)):
100
+ # Return generic message — str(exc) can leak internal paths/secrets
101
+ return f"Invalid input: {type(exc).__name__}"
102
+ if isinstance(exc, CRPError):
103
+ # CRP errors have structured codes — truncate message to prevent leakage (§audit3 SEC-H3)
104
+ msg = (exc.message or "")[:ERROR_MSG_MAX_LEN].replace("\n", " ")
105
+ return f"CRP-{exc.code}: {msg}"
106
+ # Generic error — don't leak internals
107
+ return "Internal server error"
108
+
109
+ # ── Constants (§audit4 CQ-M1) ────────────────────────────────────────
110
+ MAX_BODY_BYTES_DEFAULT = 10 * 1024 * 1024 # 10 MB body limit
111
+ MAX_SESSIONS_DEFAULT = 64 # Max concurrent sessions
112
+ RATE_WINDOW_DEFAULT = 60 # Rate window in seconds
113
+ RATE_MAX_REQUESTS_DEFAULT = 120 # Max requests per IP per window
114
+ DISPATCH_RATE_WINDOW_DEFAULT = 60 # Dispatch rate window
115
+ DISPATCH_RATE_MAX_DEFAULT = 30 # Max dispatch requests per caller
116
+ RATE_COUNTER_MAX_KEYS = 10_000 # Max tracked IPs before eviction
117
+ RATE_COUNTER_EVICT_COUNT = 2_000 # Number of oldest IPs to evict
118
+ ERROR_MSG_MAX_LEN = 80 # Error message truncation length
119
+
120
+ # ── Server state ─────────────────────────────────────────────────────
121
+ _active_sessions: dict[str, Any] = {}
122
+ _session_owners: dict[str, str] = {} # session_id → token_hash that created it
123
+ _sessions_lock = threading.Lock()
124
+ _auth_token: str | None = None # Optional bearer token (set via start_sidecar)
125
+ _max_body_bytes: int = MAX_BODY_BYTES_DEFAULT
126
+ _max_sessions: int = MAX_SESSIONS_DEFAULT
127
+
128
+ # ── Rate limiting state ──────────────────────────────────────────────
129
+ _rate_window_seconds: int = RATE_WINDOW_DEFAULT
130
+ _rate_max_requests: int = RATE_MAX_REQUESTS_DEFAULT
131
+ _rate_counters: dict[str, deque] = defaultdict(lambda: deque(maxlen=RATE_MAX_REQUESTS_DEFAULT))
132
+ _rate_lock = threading.Lock()
133
+
134
+ # Dispatch-specific rate limit (per API-key / per-IP, more expensive)
135
+ _dispatch_rate_window: int = DISPATCH_RATE_WINDOW_DEFAULT
136
+ _dispatch_rate_max: int = DISPATCH_RATE_MAX_DEFAULT
137
+ _dispatch_counters: dict[str, list[float]] = defaultdict(list)
138
+
139
+
140
+ def _check_rate_limit(client_ip: str) -> bool:
141
+ """Return True if request is allowed, False if rate-limited."""
142
+ now = time.monotonic()
143
+ with _rate_lock:
144
+ window = _rate_counters[client_ip]
145
+ # Prune old entries (deque keeps bounded maxlen)
146
+ cutoff = now - _rate_window_seconds
147
+ while window and window[0] <= cutoff:
148
+ window.popleft()
149
+ if len(window) >= _rate_max_requests:
150
+ return False
151
+ window.append(now)
152
+ # Evict oldest IP keys to prevent unbounded growth (§audit2 SEC-H3, §audit3 SEC-C1)
153
+ if len(_rate_counters) > RATE_COUNTER_MAX_KEYS:
154
+ oldest = sorted(_rate_counters.items(),
155
+ key=lambda kv: max(kv[1]) if kv[1] else 0.0)
156
+ for ip, _ in oldest[:RATE_COUNTER_EVICT_COUNT]:
157
+ del _rate_counters[ip]
158
+ return True
159
+
160
+
161
+ def _check_dispatch_rate(caller_key: str) -> bool:
162
+ """Return True if dispatch request is allowed for this caller."""
163
+ now = time.monotonic()
164
+ with _rate_lock:
165
+ window = _dispatch_counters[caller_key]
166
+ cutoff = now - _dispatch_rate_window
167
+ _dispatch_counters[caller_key] = [t for t in window if t > cutoff]
168
+ if len(_dispatch_counters[caller_key]) >= _dispatch_rate_max:
169
+ return False
170
+ _dispatch_counters[caller_key].append(now)
171
+ # Evict oldest keys to prevent unbounded growth (§audit3 SEC-H5)
172
+ if len(_dispatch_counters) > RATE_COUNTER_MAX_KEYS:
173
+ oldest = sorted(_dispatch_counters.items(),
174
+ key=lambda kv: max(kv[1]) if kv[1] else 0.0)
175
+ for key, _ in oldest[:RATE_COUNTER_EVICT_COUNT]:
176
+ del _dispatch_counters[key]
177
+ return True
178
+
179
+
180
+ def _token_hash(token: str) -> str:
181
+ """One-way hash of bearer token for ownership tracking."""
182
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
183
+
184
+
185
+ def _json_response(handler: BaseHTTPRequestHandler, status: int, data: Any) -> None:
186
+ """Write a JSON response with security headers."""
187
+ body = json.dumps(data, default=str).encode("utf-8")
188
+ handler.send_response(status)
189
+ handler.send_header("Content-Type", "application/json")
190
+ handler.send_header("Content-Length", str(len(body)))
191
+ handler.send_header("X-Content-Type-Options", "nosniff")
192
+ handler.send_header("Cache-Control", "no-store")
193
+ handler.end_headers()
194
+ handler.wfile.write(body)
195
+
196
+
197
+ def _read_body(handler: BaseHTTPRequestHandler) -> dict:
198
+ """Read and parse JSON request body with size limit."""
199
+ content_length = int(handler.headers.get("Content-Length", 0))
200
+ if content_length <= 0:
201
+ return {}
202
+ if content_length > _max_body_bytes:
203
+ raise ValueError(f"Request body too large ({content_length} bytes, max {_max_body_bytes})")
204
+ raw = handler.rfile.read(content_length)
205
+ return json.loads(raw.decode("utf-8"))
206
+
207
+
208
+ def _check_auth(handler: BaseHTTPRequestHandler) -> bool:
209
+ """Check bearer token if authentication is configured.
210
+
211
+ When no auth_token is set, access is permitted only because
212
+ start_sidecar() enforces auth for non-loopback binds (§audit3 SEC-H4).
213
+ """
214
+ if _auth_token is None:
215
+ return True
216
+ auth = handler.headers.get("Authorization", "")
217
+ if not auth.startswith("Bearer "):
218
+ _json_response(handler, 401, {"error": "Unauthorized", "detail": "Bearer token required"})
219
+ return False
220
+ provided = auth[7:]
221
+ if secrets.compare_digest(provided, _auth_token):
222
+ return True
223
+ _json_response(handler, 401, {"error": "Unauthorized", "detail": "Invalid bearer token"})
224
+ return False
225
+
226
+
227
+ def _get_caller_token(handler: BaseHTTPRequestHandler) -> str:
228
+ """Extract and hash the caller's bearer token (for ownership tracking)."""
229
+ auth = handler.headers.get("Authorization", "")
230
+ if auth.startswith("Bearer "):
231
+ return _token_hash(auth[7:])
232
+ return "anonymous"
233
+
234
+
235
+ def _check_session_access(handler: BaseHTTPRequestHandler, session_id: str) -> bool:
236
+ """Verify caller owns (or has access to) the session."""
237
+ if _auth_token is None:
238
+ return True # No auth = no ownership enforcement
239
+ caller = _get_caller_token(handler)
240
+ owner = _session_owners.get(session_id, caller)
241
+ if caller == owner:
242
+ return True
243
+ _json_response(handler, 403, {"error": "Forbidden", "detail": "You do not own this session"})
244
+ return False
245
+
246
+
247
+ class CRPSidecarHandler(BaseHTTPRequestHandler):
248
+ """HTTP request handler for CRP sidecar endpoints.
249
+
250
+ Security enforced at every layer:
251
+ 1. Rate limiting (per-IP)
252
+ 2. Bearer token authentication
253
+ 3. Session ownership verification
254
+ 4. Request body size limits
255
+ 5. Input validation on all endpoints
256
+ """
257
+
258
+ def log_message(self, format: str, *args: Any) -> None:
259
+ """Route access logs through Python logging."""
260
+ logger.info(format, *args)
261
+
262
+ # ── Pre-flight checks ────────────────────────────────────
263
+
264
+ def _pre_flight(self) -> bool:
265
+ """Run rate-limit + auth checks. Return True if request may proceed."""
266
+ client_ip = self.client_address[0]
267
+ if not _check_rate_limit(client_ip):
268
+ _json_response(self, 429, {"error": "Too Many Requests",
269
+ "detail": f"Rate limit: {_rate_max_requests} req/{_rate_window_seconds}s"})
270
+ return False
271
+ if not _check_auth(self):
272
+ return False
273
+ return True
274
+
275
+ def _get_session(self, session_id: str) -> Any | None:
276
+ """Look up session with ownership check. Returns orchestrator or None (after sending error)."""
277
+ with _sessions_lock:
278
+ orch = _active_sessions.get(session_id)
279
+ if orch is None:
280
+ # Generic 404 prevents session-ID enumeration (§audit5 SEC-L5)
281
+ _json_response(self, 404, {"error": "Not found"})
282
+ return None
283
+ if not _check_session_access(self, session_id):
284
+ return None
285
+ return orch
286
+
287
+ # ── GET routing ──────────────────────────────────────────
288
+
289
+ def do_GET(self) -> None:
290
+ if not self._pre_flight():
291
+ return
292
+ parsed = urlparse(self.path)
293
+ parts = [p for p in parsed.path.strip("/").split("/") if p]
294
+
295
+ if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "status":
296
+ self._handle_session_status(parts[1])
297
+ elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "facts":
298
+ self._handle_get_facts(parts[1], parse_qs(parsed.query))
299
+ elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "envelope":
300
+ self._handle_preview_envelope(parts[1], parse_qs(parsed.query))
301
+ elif len(parts) == 1 and parts[0] == "health":
302
+ self._handle_health()
303
+ elif len(parts) == 1 and parts[0] == "ready":
304
+ self._handle_ready()
305
+ elif len(parts) == 1 and parts[0] == "metrics":
306
+ self._handle_metrics()
307
+ elif len(parts) == 1 and parts[0] == "sessions":
308
+ self._handle_list_sessions()
309
+ else:
310
+ _json_response(self, 404, {"error": "Not found"})
311
+
312
+ # ── POST routing ─────────────────────────────────────────
313
+
314
+ def do_POST(self) -> None:
315
+ if not self._pre_flight():
316
+ return
317
+ parsed = urlparse(self.path)
318
+ parts = [p for p in parsed.path.strip("/").split("/") if p]
319
+
320
+ # POST /sessions
321
+ if len(parts) == 1 and parts[0] == "sessions":
322
+ self._handle_create_session()
323
+
324
+ # POST /sessions/:id/dispatch[/variant]
325
+ elif len(parts) >= 3 and parts[0] == "sessions" and parts[2] == "dispatch":
326
+ variant = parts[3] if len(parts) == 4 else "basic"
327
+ self._handle_dispatch(parts[1], variant)
328
+
329
+ # POST /sessions/:id/ingest
330
+ elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "ingest":
331
+ self._handle_ingest(parts[1])
332
+
333
+ # POST /sessions/:id/facts/share
334
+ elif len(parts) == 4 and parts[0] == "sessions" and parts[2] == "facts" and parts[3] == "share":
335
+ self._handle_share_facts(parts[1])
336
+
337
+ # POST /sessions/:id/facts/feedback
338
+ elif len(parts) == 4 and parts[0] == "sessions" and parts[2] == "facts" and parts[3] == "feedback":
339
+ self._handle_feedback(parts[1])
340
+
341
+ # POST /sessions/:id/providers
342
+ elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "providers":
343
+ self._handle_register_provider(parts[1])
344
+
345
+ # POST /sessions/:id/estimate
346
+ elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "estimate":
347
+ self._handle_estimate(parts[1])
348
+
349
+ # POST /sessions/:id/close
350
+ elif len(parts) == 3 and parts[0] == "sessions" and parts[2] == "close":
351
+ self._handle_close(parts[1])
352
+
353
+ else:
354
+ _json_response(self, 404, {"error": "Not found"})
355
+
356
+ # ── Health & list ────────────────────────────────────────
357
+
358
+ def _handle_health(self) -> None:
359
+ with _sessions_lock:
360
+ count = len(_active_sessions)
361
+ _json_response(self, 200, {
362
+ "status": "ok",
363
+ "active_sessions": count,
364
+ "max_sessions": _max_sessions,
365
+ "auth_required": _auth_token is not None,
366
+ "rate_limit": f"{_rate_max_requests}/{_rate_window_seconds}s",
367
+ "version": "2.0.0",
368
+ })
369
+
370
+ def _handle_ready(self) -> None:
371
+ """Readiness probe for orchestration platforms (K8s, ECS) (§audit H8)."""
372
+ with _sessions_lock:
373
+ count = len(_active_sessions)
374
+ can_accept = count < _max_sessions
375
+ status = 200 if can_accept else 503
376
+ _json_response(self, status, {
377
+ "ready": can_accept,
378
+ "active_sessions": count,
379
+ "max_sessions": _max_sessions,
380
+ })
381
+
382
+ def _handle_metrics(self) -> None:
383
+ """Expose Prometheus-compatible metrics (§audit M14)."""
384
+ try:
385
+ from crp.observability.metrics import MetricsExporter, ExportFormat
386
+ exporter = MetricsExporter()
387
+ # Collect live session metrics
388
+ with _sessions_lock:
389
+ exporter.gauge("sidecar.active_sessions", len(_active_sessions))
390
+ for sid, orch in _active_sessions.items():
391
+ try:
392
+ st = orch.session_status()
393
+ exporter.gauge("sidecar.facts_in_warm_store", st.facts_in_warm_state)
394
+ except Exception:
395
+ pass
396
+ text = exporter.export(ExportFormat.PROMETHEUS)
397
+ self.send_response(200)
398
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
399
+ encoded = text.encode("utf-8")
400
+ self.send_header("Content-Length", str(len(encoded)))
401
+ self.end_headers()
402
+ self.wfile.write(encoded)
403
+ except Exception:
404
+ logger.exception("Metrics export failed")
405
+ _json_response(self, 500, {"error": "Metrics export failed"})
406
+
407
+ def _handle_list_sessions(self) -> None:
408
+ caller = _get_caller_token(self)
409
+ with _sessions_lock:
410
+ snapshot = list(_active_sessions.items())
411
+ sessions = []
412
+ for sid, orch in snapshot:
413
+ # Only show sessions the caller owns (or all if no auth)
414
+ if _auth_token is not None and _session_owners.get(sid) != caller:
415
+ continue
416
+ try:
417
+ st = orch.session_status()
418
+ sessions.append({
419
+ "session_id": sid,
420
+ "windows_completed": st.windows_completed,
421
+ "facts_in_warm_state": st.facts_in_warm_state,
422
+ })
423
+ except Exception:
424
+ sessions.append({"session_id": sid, "status": "error"})
425
+ _json_response(self, 200, {"sessions": sessions})
426
+
427
+ # ── Session lifecycle ────────────────────────────────────
428
+
429
+ def _handle_create_session(self) -> None:
430
+ with _sessions_lock:
431
+ if len(_active_sessions) >= _max_sessions:
432
+ _json_response(self, 503, {"error": "Max sessions reached",
433
+ "detail": f"Limit: {_max_sessions}"})
434
+ return
435
+ try:
436
+ body = _read_body(self)
437
+ except ValueError as exc:
438
+ _json_response(self, 413, {"error": _sanitize_error(exc)})
439
+ return
440
+ try:
441
+ from crp.providers.custom import CustomProvider
442
+ from crp.core.orchestrator import CRPOrchestrator
443
+
444
+ context_window = body.get("context_window", 128_000)
445
+ model_name = body.get("model", "sidecar-custom")
446
+
447
+ provider = CustomProvider(
448
+ generate_fn=lambda msgs, **kw: ("", "stop"),
449
+ count_tokens_fn=lambda t: max(1, len(t) // 4),
450
+ context_size=context_window,
451
+ name=model_name,
452
+ )
453
+
454
+ orch = CRPOrchestrator(provider=provider)
455
+ session_id = orch._session.session_id
456
+ caller = _get_caller_token(self)
457
+ with _sessions_lock:
458
+ _active_sessions[session_id] = orch
459
+ _session_owners[session_id] = caller
460
+
461
+ _json_response(self, 201, {
462
+ "session_id": session_id,
463
+ "model": model_name,
464
+ "context_window": context_window,
465
+ })
466
+ except Exception as exc:
467
+ logger.exception("Failed to create session")
468
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
469
+
470
+ def _handle_session_status(self, session_id: str) -> None:
471
+ orch = self._get_session(session_id)
472
+ if orch is None:
473
+ return
474
+ try:
475
+ st = orch.session_status()
476
+ _json_response(self, 200, {
477
+ "session_id": st.session_id,
478
+ "windows_completed": st.windows_completed,
479
+ "total_input_tokens": st.total_input_tokens,
480
+ "total_output_tokens": st.total_output_tokens,
481
+ "facts_in_warm_state": st.facts_in_warm_state,
482
+ "overhead_ratio": st.overhead_ratio,
483
+ })
484
+ except Exception as exc:
485
+ logger.exception("Failed to get session status for %s", session_id)
486
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
487
+
488
+ def _handle_close(self, session_id: str) -> None:
489
+ orch = self._get_session(session_id)
490
+ if orch is None:
491
+ return
492
+ try:
493
+ orch.close()
494
+ with _sessions_lock:
495
+ _active_sessions.pop(session_id, None)
496
+ _session_owners.pop(session_id, None)
497
+ _json_response(self, 200, {"closed": True, "session_id": session_id})
498
+ except Exception as exc:
499
+ logger.exception("Failed to close session %s", session_id)
500
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
501
+
502
+ # ── Dispatch (all 6 variants) ────────────────────────────
503
+
504
+ def _handle_dispatch(self, session_id: str, variant: str) -> None:
505
+ orch = self._get_session(session_id)
506
+ if orch is None:
507
+ return
508
+
509
+ # Per-caller dispatch rate limit (expensive operation)
510
+ caller_key = _get_caller_token(self) + ":" + self.client_address[0]
511
+ if not _check_dispatch_rate(caller_key):
512
+ _json_response(self, 429, {
513
+ "error": "Dispatch rate limit exceeded",
514
+ "detail": f"Max {_dispatch_rate_max} dispatches/{_dispatch_rate_window}s per caller",
515
+ })
516
+ return
517
+
518
+ try:
519
+ body = _read_body(self)
520
+ except ValueError as exc:
521
+ _json_response(self, 413, {"error": _sanitize_error(exc)})
522
+ return
523
+
524
+ system_prompt = body.get("system_prompt", "You are a helpful assistant.")
525
+ task_input = body.get("task_input", "")
526
+ if not task_input:
527
+ _json_response(self, 400, {"error": "task_input is required"})
528
+ return
529
+
530
+ valid_variants = {"basic", "tools", "reflexive", "progressive",
531
+ "stream-augmented", "agentic"}
532
+ if variant not in valid_variants:
533
+ _json_response(self, 400, {"error": f"Unknown dispatch variant '{variant}'",
534
+ "valid": sorted(valid_variants)})
535
+ return
536
+
537
+ try:
538
+ if variant == "basic":
539
+ output, report = orch.dispatch(system_prompt, task_input)
540
+ elif variant == "tools":
541
+ tools = body.get("tools", [])
542
+ if not tools:
543
+ _json_response(self, 400, {"error": "tools list required for tools dispatch"})
544
+ return
545
+ output, report = orch.dispatch_with_tools(
546
+ system_prompt, task_input, tools=tools,
547
+ )
548
+ elif variant == "reflexive":
549
+ depth = body.get("depth", 1)
550
+ output, report = orch.dispatch_reflexive(
551
+ system_prompt, task_input, depth=depth,
552
+ )
553
+ elif variant == "progressive":
554
+ output, report = orch.dispatch_progressive(
555
+ system_prompt, task_input,
556
+ )
557
+ elif variant == "stream-augmented":
558
+ # Collect streamed output into final string
559
+ chunks = []
560
+ for chunk in orch.dispatch_stream_augmented(system_prompt, task_input):
561
+ chunks.append(chunk.text if hasattr(chunk, "text") else str(chunk))
562
+ output = "".join(chunks)
563
+ report = orch.session_status() # Best-effort report
564
+ _json_response(self, 200, {
565
+ "output": output,
566
+ "variant": "stream-augmented",
567
+ "session_id": session_id,
568
+ })
569
+ return
570
+ elif variant == "agentic":
571
+ output, report = orch.dispatch_agentic(
572
+ system_prompt, task_input,
573
+ )
574
+ else:
575
+ _json_response(self, 400, {"error": f"Unhandled variant: {variant}"})
576
+ return
577
+
578
+ _json_response(self, 200, {
579
+ "output": output,
580
+ "variant": variant,
581
+ "quality_tier": report.quality_tier,
582
+ "facts_extracted": report.facts_extracted,
583
+ "continuation_windows": report.continuation_windows,
584
+ "session_id": report.session_id,
585
+ "window_id": report.window_id,
586
+ })
587
+ except Exception as exc:
588
+ logger.exception("Dispatch error (variant=%s)", variant)
589
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
590
+
591
+ # ── Ingest ───────────────────────────────────────────────
592
+
593
+ def _handle_ingest(self, session_id: str) -> None:
594
+ orch = self._get_session(session_id)
595
+ if orch is None:
596
+ return
597
+ try:
598
+ body = _read_body(self)
599
+ except ValueError as exc:
600
+ _json_response(self, 413, {"error": _sanitize_error(exc)})
601
+ return
602
+ text = body.get("text", "")
603
+ label = body.get("label", "sidecar-ingest")
604
+ if not text:
605
+ _json_response(self, 400, {"error": "text is required"})
606
+ return
607
+ try:
608
+ result = orch.ingest(text, source_label=label)
609
+ _json_response(self, 200, {
610
+ "facts_extracted": result.facts_extracted,
611
+ "source_label": result.source_label,
612
+ "fact_ids": result.fact_ids,
613
+ })
614
+ except Exception as exc:
615
+ logger.exception("Ingest failed for session %s", session_id)
616
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
617
+
618
+ # ── Facts: query, share, feedback ────────────────────────
619
+
620
+ def _handle_get_facts(self, session_id: str, params: dict) -> None:
621
+ orch = self._get_session(session_id)
622
+ if orch is None:
623
+ return
624
+ try:
625
+ limit = min(int(params.get("limit", ["50"])[0]), 500)
626
+ ranked_facts = orch._warm_store.get_ranked_facts(limit=limit)
627
+ facts = [
628
+ {
629
+ "id": f.id,
630
+ "text": f.text,
631
+ "confidence": round(f.confidence, 3),
632
+ "source_window_id": f.source_window_id,
633
+ "extraction_stage": f.extraction_stage,
634
+ }
635
+ for f in ranked_facts
636
+ ]
637
+ _json_response(self, 200, {"facts": facts, "total": len(facts)})
638
+ except Exception as exc:
639
+ logger.exception("Failed to get facts for session %s", session_id)
640
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
641
+
642
+ def _handle_share_facts(self, source_session_id: str) -> None:
643
+ """Share facts FROM source session TO target session.
644
+
645
+ This is the **core inter-LLM context sharing** endpoint:
646
+ - Session A (Claude) extracts facts about code architecture
647
+ - Session B (GPT-4) receives those facts via this endpoint
648
+ - Session B's next dispatch envelope automatically includes
649
+ Session A's knowledge — no LLM-to-LLM communication needed
650
+
651
+ Request body::
652
+
653
+ {
654
+ "target_session_id": "...",
655
+ "limit": 50,
656
+ "min_confidence": 0.5
657
+ }
658
+ """
659
+ source_orch = self._get_session(source_session_id)
660
+ if source_orch is None:
661
+ return
662
+
663
+ try:
664
+ body = _read_body(self)
665
+ except ValueError as exc:
666
+ _json_response(self, 413, {"error": _sanitize_error(exc)})
667
+ return
668
+
669
+ target_session_id = body.get("target_session_id", "")
670
+ if not target_session_id:
671
+ _json_response(self, 400, {"error": "target_session_id is required"})
672
+ return
673
+
674
+ # Verify caller also owns target session
675
+ target_orch = self._get_session(target_session_id)
676
+ if target_orch is None:
677
+ return
678
+
679
+ limit = min(int(body.get("limit", 50)), 500)
680
+ min_confidence = max(0.0, min(1.0, float(body.get("min_confidence", 0.3))))
681
+
682
+ try:
683
+ source_facts = source_orch._warm_store.get_ranked_facts(limit=limit)
684
+ shared_facts = [f for f in source_facts if f.confidence >= min_confidence]
685
+
686
+ if shared_facts:
687
+ target_orch._warm_store.add_facts(shared_facts)
688
+ target_orch._ckf.store(
689
+ shared_facts,
690
+ window_id=f"shared-from-{source_session_id[:8]}",
691
+ )
692
+
693
+ shared_count = len(shared_facts)
694
+ logger.info(
695
+ "Shared %d facts from session %s → %s",
696
+ shared_count, source_session_id[:8], target_session_id[:8],
697
+ )
698
+
699
+ source_orch._emitter.emit("fact.shared", {
700
+ "target_session_id": target_session_id,
701
+ "facts_shared": shared_count,
702
+ })
703
+ target_orch._emitter.emit("fact.received", {
704
+ "source_session_id": source_session_id,
705
+ "facts_received": shared_count,
706
+ })
707
+
708
+ _json_response(self, 200, {
709
+ "facts_shared": shared_count,
710
+ "source_session_id": source_session_id,
711
+ "target_session_id": target_session_id,
712
+ })
713
+ except Exception as exc:
714
+ logger.exception("Share facts failed from %s", source_session_id)
715
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
716
+
717
+ def _handle_feedback(self, session_id: str) -> None:
718
+ """Adjust fact confidence or reject a fact.
719
+
720
+ Request body::
721
+
722
+ {
723
+ "fact_id": "...",
724
+ "action": "boost" | "penalize" | "reject",
725
+ "delta": 0.1,
726
+ "reason": "User confirmed this fact"
727
+ }
728
+ """
729
+ orch = self._get_session(session_id)
730
+ if orch is None:
731
+ return
732
+ try:
733
+ body = _read_body(self)
734
+ except ValueError as exc:
735
+ _json_response(self, 413, {"error": _sanitize_error(exc)})
736
+ return
737
+
738
+ fact_id = body.get("fact_id", "")
739
+ action = body.get("action", "")
740
+ if not fact_id or action not in ("boost", "penalize", "reject"):
741
+ _json_response(self, 400, {
742
+ "error": "fact_id and action (boost|penalize|reject) required",
743
+ })
744
+ return
745
+
746
+ delta = max(0.0, min(1.0, float(body.get("delta", 0.1))))
747
+ reason = body.get("reason", "sidecar-feedback")
748
+
749
+ try:
750
+ if action == "boost":
751
+ orch.boost_fact(fact_id, delta=delta, reason=reason)
752
+ elif action == "penalize":
753
+ orch.penalize_fact(fact_id, delta=delta, reason=reason)
754
+ elif action == "reject":
755
+ orch.reject_fact(fact_id, reason=reason)
756
+
757
+ _json_response(self, 200, {
758
+ "fact_id": fact_id,
759
+ "action": action,
760
+ "applied": True,
761
+ })
762
+ except Exception as exc:
763
+ logger.exception("Feedback failed for fact %s in session %s", fact_id, session_id)
764
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
765
+
766
+ # ── Envelope preview ─────────────────────────────────────
767
+
768
+ def _handle_preview_envelope(self, session_id: str, params: dict) -> None:
769
+ orch = self._get_session(session_id)
770
+ if orch is None:
771
+ return
772
+ try:
773
+ task = params.get("task", ["Preview task"])[0]
774
+ system = params.get("system", ["You are a helpful assistant."])[0]
775
+ ep = orch.preview_envelope(system, task)
776
+ _json_response(self, 200, {
777
+ "total_tokens": ep.total_tokens,
778
+ "envelope_tokens": ep.envelope_tokens,
779
+ "generation_reserve": ep.generation_reserve,
780
+ "facts_included": ep.facts_included,
781
+ "facts_available": ep.facts_available,
782
+ "saturation": round(ep.saturation, 4),
783
+ })
784
+ except Exception as exc:
785
+ logger.exception("Envelope preview failed for session %s", session_id)
786
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
787
+
788
+ # ── Cost estimation ──────────────────────────────────────
789
+
790
+ def _handle_estimate(self, session_id: str) -> None:
791
+ orch = self._get_session(session_id)
792
+ if orch is None:
793
+ return
794
+ try:
795
+ body = _read_body(self)
796
+ except ValueError as exc:
797
+ _json_response(self, 413, {"error": _sanitize_error(exc)})
798
+ return
799
+ system_prompt = body.get("system_prompt", "You are a helpful assistant.")
800
+ task_input = body.get("task_input", "")
801
+ planned = body.get("planned_dispatches", 1)
802
+ if not task_input:
803
+ _json_response(self, 400, {"error": "task_input is required"})
804
+ return
805
+ try:
806
+ est = orch.estimate_session(system_prompt, task_input,
807
+ planned_dispatches=planned)
808
+ _json_response(self, 200, {
809
+ "estimated_input_tokens": est.estimated_input_tokens,
810
+ "estimated_output_tokens": est.estimated_output_tokens,
811
+ "estimated_windows": est.estimated_windows,
812
+ "estimated_cost_usd": est.estimated_cost_usd
813
+ if hasattr(est, "estimated_cost_usd") else None,
814
+ })
815
+ except Exception as exc:
816
+ logger.exception("Cost estimation failed for session %s", session_id)
817
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
818
+
819
+ # ── Provider registration ────────────────────────────────
820
+
821
+ def _handle_register_provider(self, session_id: str) -> None:
822
+ """Register a fallback provider for this session.
823
+
824
+ Request body::
825
+
826
+ {
827
+ "model": "gpt-4",
828
+ "context_window": 128000
829
+ }
830
+ """
831
+ orch = self._get_session(session_id)
832
+ if orch is None:
833
+ return
834
+ try:
835
+ body = _read_body(self)
836
+ except ValueError as exc:
837
+ _json_response(self, 413, {"error": _sanitize_error(exc)})
838
+ return
839
+
840
+ model_name = body.get("model", "")
841
+ context_window = body.get("context_window", 128_000)
842
+ if not model_name:
843
+ _json_response(self, 400, {"error": "model name required"})
844
+ return
845
+ try:
846
+ from crp.providers.custom import CustomProvider
847
+
848
+ provider = CustomProvider(
849
+ generate_fn=lambda msgs, **kw: ("", "stop"),
850
+ count_tokens_fn=lambda t: max(1, len(t) // 4),
851
+ context_size=context_window,
852
+ name=model_name,
853
+ )
854
+ orch.register_provider(provider)
855
+ _json_response(self, 200, {
856
+ "registered": True,
857
+ "model": model_name,
858
+ "context_window": context_window,
859
+ })
860
+ except Exception as exc:
861
+ logger.exception("Provider registration failed for session %s", session_id)
862
+ _json_response(self, 500, {"error": _sanitize_error(exc)})
863
+
864
+
865
+ # ── Server factory ───────────────────────────────────────────────────
866
+
867
+
868
+ def start_sidecar(
869
+ host: str = "127.0.0.1",
870
+ port: int = 9470,
871
+ auth_token: str | None = None,
872
+ max_body_bytes: int = 10 * 1024 * 1024,
873
+ max_sessions: int = 64,
874
+ rate_limit: int = 120,
875
+ rate_window: int = 60,
876
+ dispatch_rate_limit: int = 30,
877
+ dispatch_rate_window: int = 60,
878
+ ) -> HTTPServer:
879
+ """Start the CRP HTTP sidecar.
880
+
881
+ The sidecar is **optional** — it is never started automatically by the
882
+ library. Users must explicitly invoke ``crp serve`` or call this
883
+ function from their own code.
884
+
885
+ Args:
886
+ host: Bind address. Default ``127.0.0.1`` (loopback only).
887
+ port: Port number. Default ``9470``.
888
+ auth_token: Bearer token for authentication. **Strongly
889
+ recommended** when binding to non-loopback addresses.
890
+ max_body_bytes: Maximum request body size (default 10 MB).
891
+ max_sessions: Maximum concurrent sessions (default 64).
892
+ rate_limit: Maximum requests per IP per rate window (default 120).
893
+ rate_window: Rate-limit window in seconds (default 60).
894
+ dispatch_rate_limit: Max dispatch requests per caller per window (default 30).
895
+ dispatch_rate_window: Dispatch rate-limit window in seconds (default 60).
896
+
897
+ Returns:
898
+ HTTPServer instance (call ``.serve_forever()`` to start).
899
+ """
900
+ global _auth_token, _max_body_bytes, _max_sessions
901
+ global _rate_max_requests, _rate_window_seconds
902
+ global _dispatch_rate_max, _dispatch_rate_window
903
+
904
+ # Enforce auth when binding to non-loopback addresses (§audit3 SEC-H4)
905
+ if host not in ("127.0.0.1", "localhost", "::1") and auth_token is None:
906
+ raise ValueError(
907
+ f"Binding to '{host}' without auth_token is insecure. "
908
+ "Pass auth_token or bind to 127.0.0.1 for local-only access."
909
+ )
910
+
911
+ if auth_token is None:
912
+ logger.warning(
913
+ "Sidecar starting without auth_token on %s:%d — "
914
+ "unauthenticated access allowed (§audit4 SEC-H1)",
915
+ host, port,
916
+ )
917
+
918
+ _auth_token = auth_token
919
+ _max_body_bytes = max_body_bytes
920
+ _max_sessions = max_sessions
921
+ _rate_max_requests = rate_limit
922
+ _rate_window_seconds = rate_window
923
+ _dispatch_rate_max = dispatch_rate_limit
924
+ _dispatch_rate_window = dispatch_rate_window
925
+
926
+ server = HTTPServer((host, port), CRPSidecarHandler)
927
+ logger.info("CRP sidecar started on %s:%d (auth=%s, max_sessions=%d)",
928
+ host, port, "enabled" if auth_token else "disabled", max_sessions)
929
+ return server