@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0

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 (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. package/package.json +9 -2
@@ -0,0 +1,88 @@
1
+ """Thread-safe in-memory state for channel wake delivery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from threading import Lock, RLock
7
+ from typing import Any, Callable
8
+
9
+
10
+ def _message_id(message: dict[str, Any]) -> int:
11
+ try:
12
+ return int(message.get("id") or 0)
13
+ except (TypeError, ValueError):
14
+ return 0
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class ChannelWakeDeliveryRepository:
19
+ lock: Lock | RLock
20
+ delivered: set[int]
21
+ prompts: dict[int, str]
22
+ clear_claim: Callable[[int], None]
23
+ commit_cursor: Callable[[int], None]
24
+ retained_limit: int = 1000
25
+ prune_count: int = 500
26
+
27
+ def prompt(self, message_id: int) -> str:
28
+ with self.lock:
29
+ return self.prompts.get(message_id, "")
30
+
31
+ def is_delivered(self, message_id: int) -> bool:
32
+ with self.lock:
33
+ return message_id in self.delivered
34
+
35
+ def release_stale(self, message_id: int, commit_cursor: bool) -> None:
36
+ with self.lock:
37
+ self.delivered.discard(message_id)
38
+ self.prompts.pop(message_id, None)
39
+ self.clear_claim(message_id)
40
+ if commit_cursor:
41
+ self.commit_cursor(message_id)
42
+
43
+ def complete(self, message_id: int) -> None:
44
+ with self.lock:
45
+ self.prompts.pop(message_id, None)
46
+ self.clear_claim(message_id)
47
+
48
+ def mark_delivered(self, message_id: int) -> bool:
49
+ with self.lock:
50
+ if message_id in self.delivered:
51
+ return False
52
+ self.delivered.add(message_id)
53
+ self._prune(self.delivered)
54
+ return True
55
+
56
+ def record_prompts(self, messages: list[dict[str, Any]], prompt: str) -> None:
57
+ with self.lock:
58
+ for message in messages:
59
+ message_id = _message_id(message)
60
+ if message_id > 0:
61
+ self.prompts[message_id] = prompt
62
+ self._prune(self.prompts)
63
+
64
+ def rollback(
65
+ self, messages: list[dict[str, Any]], claimed_ids: list[int]
66
+ ) -> None:
67
+ with self.lock:
68
+ for message in messages:
69
+ message_id = _message_id(message)
70
+ if message_id <= 0:
71
+ continue
72
+ self.delivered.discard(message_id)
73
+ self.prompts.pop(message_id, None)
74
+ self.clear_claim(message_id)
75
+ for message_id in claimed_ids:
76
+ self.clear_claim(message_id)
77
+
78
+ def _prune(self, collection: set[int] | dict[int, str]) -> None:
79
+ if len(collection) <= self.retained_limit:
80
+ return
81
+ for old_id in sorted(collection)[: self.prune_count]:
82
+ if isinstance(collection, set):
83
+ collection.discard(old_id)
84
+ else:
85
+ collection.pop(old_id, None)
86
+
87
+
88
+ __all__ = ["ChannelWakeDeliveryRepository"]
@@ -0,0 +1,138 @@
1
+ """Chat attachment storage and message projection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from dataclasses import dataclass
7
+ import mimetypes
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import time
12
+ from typing import Any, Callable
13
+ import urllib.parse
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ChatFilePorts:
18
+ timestamp: Callable[[], float] = time.time
19
+ timestamp_ns: Callable[[], int] = time.time_ns
20
+
21
+
22
+ class ChatFileRepository:
23
+ DEFAULT_MAX_BYTES = 25 * 1024 * 1024
24
+
25
+ def __init__(
26
+ self,
27
+ root: Path,
28
+ router_base: str,
29
+ ports: ChatFilePorts | None = None,
30
+ ) -> None:
31
+ self._root = root
32
+ self._router_base = router_base
33
+ self._ports = ports or ChatFilePorts()
34
+
35
+ @classmethod
36
+ def configured_max_bytes(cls) -> int:
37
+ try:
38
+ value = int(str(os.environ.get("CIEL_RUNTIME_CHAT_FILE_MAX_BYTES") or "").strip())
39
+ return value if value > 0 else cls.DEFAULT_MAX_BYTES
40
+ except ValueError:
41
+ return cls.DEFAULT_MAX_BYTES
42
+
43
+ @staticmethod
44
+ def safe_segment(value: str, fallback: str = "item") -> str:
45
+ text = re.sub(r"[^A-Za-z0-9._-]+", "-", (value or "").strip()).strip(".-")
46
+ return text[:120] or fallback
47
+
48
+ def store_upload(self, body: dict[str, Any]) -> dict[str, Any]:
49
+ self._root.mkdir(parents=True, exist_ok=True)
50
+ raw_name = str(
51
+ body.get("name") or f"file-{int(self._ports.timestamp())}.txt"
52
+ ).strip() or "file"
53
+ content = body.get("content", "")
54
+ encoding = str(body.get("encoding") or "utf-8").strip().lower()
55
+ if encoding == "base64":
56
+ try:
57
+ data = base64.b64decode(str(content).encode("ascii"), validate=True)
58
+ except (ValueError, UnicodeEncodeError) as exc:
59
+ raise ValueError("invalid base64 file content") from exc
60
+ elif encoding in {"", "text", "utf-8", "utf8"}:
61
+ data = str(content).encode("utf-8")
62
+ else:
63
+ raise ValueError(f"unsupported file encoding: {encoding}")
64
+ self._validate_size(data)
65
+ name = f"{self._ports.timestamp_ns()}-{self.safe_segment(raw_name, 'file')}"
66
+ target = self._root / name
67
+ target.write_bytes(data)
68
+ path = f"/ca/chat/files/{urllib.parse.quote(name)}"
69
+ content_type = str(
70
+ body.get("content_type") or body.get("mime_type") or "application/octet-stream"
71
+ ).strip()
72
+ return {
73
+ "name": name,
74
+ "original_name": raw_name,
75
+ "url": f"{self._router_base}{path}",
76
+ "path": path,
77
+ "bytes": len(data),
78
+ "content_type": content_type[:200] or "application/octet-stream",
79
+ }
80
+
81
+ def store_path(
82
+ self,
83
+ path_value: Any,
84
+ name: str | None = None,
85
+ content_type: str | None = None,
86
+ ) -> dict[str, Any]:
87
+ raw_path = str(path_value or "").strip()
88
+ if not raw_path:
89
+ raise ValueError("file path is required")
90
+ source = Path(raw_path).expanduser()
91
+ if not source.exists() or not source.is_file():
92
+ raise FileNotFoundError(f"file not found: {raw_path}")
93
+ data = source.read_bytes()
94
+ self._validate_size(data)
95
+ guessed_type = content_type or mimetypes.guess_type(source.name)[0] or "application/octet-stream"
96
+ return self.store_upload(
97
+ {
98
+ "name": name or source.name,
99
+ "encoding": "base64",
100
+ "content": base64.b64encode(data).decode("ascii"),
101
+ "content_type": guessed_type,
102
+ }
103
+ )
104
+
105
+ @staticmethod
106
+ def markdown_lines(uploads: list[dict[str, Any]]) -> list[str]:
107
+ lines: list[str] = []
108
+ for upload in uploads:
109
+ label = str(upload.get("original_name") or upload.get("name") or "file")
110
+ url = str(upload.get("url") or upload.get("path") or "")
111
+ byte_count = upload.get("bytes")
112
+ content_type = str(upload.get("content_type") or "application/octet-stream")
113
+ details = []
114
+ if isinstance(byte_count, int):
115
+ details.append(f"{byte_count} bytes")
116
+ if content_type:
117
+ details.append(content_type)
118
+ detail = f" ({', '.join(details)})" if details else ""
119
+ lines.append(f"- [{label}]({url}){detail}")
120
+ return lines
121
+
122
+ @classmethod
123
+ def message_text(cls, message: str, uploads: list[dict[str, Any]]) -> str:
124
+ body = str(message or "").strip()
125
+ lines = cls.markdown_lines(uploads)
126
+ if not lines:
127
+ return body
128
+ attachment_text = "Attached files:\n" + "\n".join(lines)
129
+ return f"{body}\n\n{attachment_text}" if body else attachment_text
130
+
131
+ @classmethod
132
+ def _validate_size(cls, data: bytes) -> None:
133
+ max_bytes = cls.configured_max_bytes()
134
+ if len(data) > max_bytes:
135
+ raise OverflowError(f"file too large: {len(data)} bytes exceeds {max_bytes} bytes")
136
+
137
+
138
+ __all__ = ["ChatFilePorts", "ChatFileRepository"]
@@ -0,0 +1,235 @@
1
+ """HTTP controller for the Ciel Runtime chat/channel bridge API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.parse
8
+ from dataclasses import dataclass
9
+ from http.server import BaseHTTPRequestHandler
10
+ from pathlib import Path
11
+ from threading import Condition
12
+ from typing import Any, Callable
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class ChatHttpReadServices:
17
+ read_after: Callable[[int, str | None, str | None, int], list[dict[str, Any]]]
18
+ read_before: Callable[[int, str | None, str | None, int], list[dict[str, Any]]]
19
+ condition: Condition
20
+ connection_statuses: Callable[[], dict[str, Any]]
21
+ safe_segment: Callable[[str, str], str]
22
+ files_dir: Path
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ChatHttpWriteServices:
27
+ write_json: Callable[..., None]
28
+ append_message: Callable[[dict[str, Any]], dict[str, Any]]
29
+ store_upload: Callable[[dict[str, Any]], dict[str, Any]]
30
+ start_connection: Callable[[dict[str, Any]], dict[str, Any]]
31
+ stop_connection: Callable[[str | None], dict[str, Any]]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ChatHttpController:
36
+ router_base: str
37
+ reads: ChatHttpReadServices
38
+ writes: ChatHttpWriteServices
39
+
40
+ @staticmethod
41
+ def _params(handler: BaseHTTPRequestHandler) -> dict[str, list[str]]:
42
+ return urllib.parse.parse_qs(urllib.parse.urlparse(handler.path).query, keep_blank_values=True)
43
+
44
+ @staticmethod
45
+ def _first(params: dict[str, list[str]], name: str, default: str = "") -> str:
46
+ values = params.get(name)
47
+ return values[0] if values else default
48
+
49
+ @staticmethod
50
+ def _chat_path(path: str) -> tuple[str, bool]:
51
+ channel_alias = path.startswith("/ca/channel/")
52
+ if channel_alias:
53
+ path = "/ca/chat/" + path[len("/ca/channel/") :]
54
+ return path, channel_alias
55
+
56
+ def get(self, handler: BaseHTTPRequestHandler, path: str) -> bool:
57
+ path, channel_alias = self._chat_path(path)
58
+ if path == "/ca/chat/health":
59
+ self.writes.write_json(
60
+ handler,
61
+ {
62
+ "ok": True,
63
+ "base": self.router_base,
64
+ "messages": "/ca/channel/messages" if channel_alias else "/ca/chat/messages",
65
+ "wait": "/ca/channel/wait" if channel_alias else "/ca/chat/wait",
66
+ "stream": "/ca/channel/stream" if channel_alias else "/ca/chat/stream",
67
+ "notify": "/ca/channel/notify",
68
+ "sse_status": "/ca/channel/sse/status",
69
+ "sse_connect": "POST /ca/channel/sse/connect",
70
+ "sse_disconnect": "POST /ca/channel/sse/disconnect",
71
+ "native_note": "This is the Ciel Runtime bridge API, not Claude Code's gated native --channels path.",
72
+ },
73
+ )
74
+ return True
75
+ if path == "/ca/chat/sse/status":
76
+ self.writes.write_json(handler, {"ok": True, "connections": self.reads.connection_statuses()})
77
+ return True
78
+ if path in ("/ca/chat/messages", "/ca/chat/wait"):
79
+ return self._messages(handler, path)
80
+ if path == "/ca/chat/stream":
81
+ return self._stream(handler)
82
+ if path.startswith("/ca/chat/files/"):
83
+ return self._file(handler, path)
84
+ return False
85
+
86
+ def _messages(self, handler: BaseHTTPRequestHandler, path: str) -> bool:
87
+ params = self._params(handler)
88
+ after = int(self._first(params, "after", "0") or 0)
89
+ before = int(self._first(params, "before", "0") or 0)
90
+ limit = max(1, min(500, int(self._first(params, "limit", "100") or 100)))
91
+ channel = self._first(params, "channel") or None
92
+ recipient = self._first(params, "recipient") or self._first(params, "recipient_id") or None
93
+ latest = self._first(params, "latest") or self._first(params, "history")
94
+ timeout = 0.0 if path.endswith("/messages") else max(
95
+ 0.0, min(300.0, float(self._first(params, "timeout", "60") or 60))
96
+ )
97
+ deadline = time.time() + timeout
98
+ history = path.endswith("/messages") and (before > 0 or latest.lower() in {"1", "true", "yes", "on"})
99
+ messages = (
100
+ self.reads.read_before(before, channel, recipient, limit)
101
+ if history
102
+ else self.reads.read_after(after, channel, recipient, limit)
103
+ )
104
+ while not messages and timeout > 0 and time.time() < deadline:
105
+ with self.reads.condition:
106
+ self.reads.condition.wait(timeout=min(5.0, max(0.0, deadline - time.time())))
107
+ messages = self.reads.read_after(after, channel, recipient, limit)
108
+ self.writes.write_json(
109
+ handler,
110
+ {
111
+ "ok": True,
112
+ "messages": messages,
113
+ "last_id": messages[-1]["id"] if messages else after,
114
+ "oldest_id": messages[0]["id"] if messages else None,
115
+ "has_more": bool(messages and (before > 0 or len(messages) >= limit)),
116
+ },
117
+ )
118
+ return True
119
+
120
+ def _stream(self, handler: BaseHTTPRequestHandler) -> bool:
121
+ params = self._params(handler)
122
+ after = int(self._first(params, "after", "0") or 0)
123
+ channel = self._first(params, "channel") or None
124
+ recipient = self._first(params, "recipient") or self._first(params, "recipient_id") or None
125
+ timeout = max(1.0, min(3600.0, float(self._first(params, "timeout", "300") or 300)))
126
+ handler.send_response(200)
127
+ handler.send_header("content-type", "text/event-stream")
128
+ handler.send_header("cache-control", "no-cache")
129
+ handler.send_header("connection", "close")
130
+ handler.end_headers()
131
+ deadline = time.time() + timeout
132
+ last_id = after
133
+ try:
134
+ while time.time() < deadline:
135
+ messages = self.reads.read_after(last_id, channel, recipient, 100)
136
+ for message in messages:
137
+ last_id = int(message["id"])
138
+ handler.wfile.write(f"id: {last_id}\n".encode())
139
+ handler.wfile.write(b"event: message\n")
140
+ data = json.dumps(message, ensure_ascii=False, separators=(",", ":"))
141
+ handler.wfile.write(f"data: {data}\n\n".encode())
142
+ handler.wfile.flush()
143
+ if messages:
144
+ continue
145
+ handler.wfile.write(b": wait\n\n")
146
+ handler.wfile.flush()
147
+ with self.reads.condition:
148
+ self.reads.condition.wait(timeout=min(15.0, max(0.0, deadline - time.time())))
149
+ except (BrokenPipeError, ConnectionError):
150
+ pass
151
+ return True
152
+
153
+ def _file(self, handler: BaseHTTPRequestHandler, path: str) -> bool:
154
+ name = self.reads.safe_segment(urllib.parse.unquote(path[len("/ca/chat/files/") :]), "file")
155
+ target = self.reads.files_dir / name
156
+ if not target.exists() or not target.is_file():
157
+ self.writes.write_json(handler, {"ok": False, "error": "not_found"}, 404)
158
+ return True
159
+ data = target.read_bytes()
160
+ handler.send_response(200)
161
+ handler.send_header("content-type", "application/octet-stream")
162
+ handler.send_header("content-disposition", f"attachment; filename={json.dumps(name)}")
163
+ handler.send_header("content-length", str(len(data)))
164
+ handler.end_headers()
165
+ handler.wfile.write(data)
166
+ return True
167
+
168
+ def post(self, handler: BaseHTTPRequestHandler, path: str, body: dict[str, Any]) -> bool:
169
+ path, _channel_alias = self._chat_path(path)
170
+ if path == "/ca/chat/sse/connect":
171
+ try:
172
+ status = self.writes.start_connection(body)
173
+ self.writes.write_json(handler, {"ok": True, "connection": status})
174
+ except Exception as error:
175
+ self.writes.write_json(handler, {"ok": False, "error": str(error)}, 400)
176
+ return True
177
+ if path == "/ca/chat/sse/disconnect":
178
+ name = body.get("name")
179
+ result = self.writes.stop_connection(str(name) if name else None)
180
+ self.writes.write_json(handler, {"ok": True, **result})
181
+ return True
182
+ if path == "/ca/chat/notify":
183
+ return self._notify(handler, body)
184
+ if path == "/ca/chat/messages":
185
+ message = self.writes.append_message(body)
186
+ self.writes.write_json(handler, {"ok": True, "message": message})
187
+ return True
188
+ if path == "/ca/chat/files":
189
+ return self._upload(handler, body)
190
+ return False
191
+
192
+ def _notify(self, handler: BaseHTTPRequestHandler, body: dict[str, Any]) -> bool:
193
+ params = body.get("params") if isinstance(body.get("params"), dict) else {}
194
+ meta = params.get("meta") if isinstance(params.get("meta"), dict) else body.get("meta")
195
+ meta = meta if isinstance(meta, dict) else {}
196
+ content = str(params.get("content") or body.get("content") or body.get("message") or body.get("text") or "")
197
+ message = self.writes.append_message(
198
+ {
199
+ "channel": body.get("channel") or meta.get("channel") or "default",
200
+ "sender_id": body.get("sender_id") or body.get("sender") or body.get("server") or meta.get("source") or "channel",
201
+ "recipients": body.get("recipients", body.get("recipient_id", meta.get("recipients", "all"))),
202
+ "thread_id": body.get("thread_id") or meta.get("thread_id"),
203
+ "parent_id": body.get("parent_id") or meta.get("parent_id"),
204
+ "kind": body.get("kind") or "channel",
205
+ "message": content,
206
+ "meta": meta,
207
+ }
208
+ )
209
+ self.writes.write_json(handler, {"ok": True, "message": message})
210
+ return True
211
+
212
+ def _upload(self, handler: BaseHTTPRequestHandler, body: dict[str, Any]) -> bool:
213
+ try:
214
+ upload = self.writes.store_upload(body)
215
+ except OverflowError as error:
216
+ self.writes.write_json(handler, {"ok": False, "error": str(error)}, 413)
217
+ return True
218
+ except ValueError as error:
219
+ self.writes.write_json(handler, {"ok": False, "error": str(error)}, 400)
220
+ return True
221
+ if body.get("announce", True):
222
+ self.writes.append_message(
223
+ {
224
+ "channel": body.get("channel", "default"),
225
+ "sender_id": body.get("sender_id", "system"),
226
+ "recipients": body.get("recipients", "all"),
227
+ "thread_id": body.get("thread_id"),
228
+ "parent_id": body.get("parent_id"),
229
+ "kind": "file",
230
+ "message": str(body.get("message") or upload["url"]),
231
+ "meta": {"attachments": [upload], "name": upload["original_name"], "url": upload["url"]},
232
+ }
233
+ )
234
+ self.writes.write_json(handler, {"ok": True, **upload})
235
+ return True