python-codex 0.2.4__py3-none-any.whl → 0.2.6__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.
- pycodex/context.py +4 -18
- pycodex/interactive_session.py +15 -1
- pycodex/model.py +153 -11
- pycodex/model_metadata.py +31 -0
- pycodex/prompts/models.json +334 -0
- pycodex/tools/unified_exec_manager.py +32 -1
- pycodex/utils/compactor.py +5 -87
- {python_codex-0.2.4.dist-info → python_codex-0.2.6.dist-info}/METADATA +34 -6
- {python_codex-0.2.4.dist-info → python_codex-0.2.6.dist-info}/RECORD +14 -13
- workspace_server/app.py +57 -1
- workspace_server/workspace.html +309 -72
- {python_codex-0.2.4.dist-info → python_codex-0.2.6.dist-info}/WHEEL +0 -0
- {python_codex-0.2.4.dist-info → python_codex-0.2.6.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.4.dist-info → python_codex-0.2.6.dist-info}/licenses/LICENSE +0 -0
pycodex/utils/compactor.py
CHANGED
|
@@ -22,6 +22,7 @@ Include:
|
|
|
22
22
|
- Important context, constraints, or user preferences
|
|
23
23
|
- What remains to be done (clear next steps)
|
|
24
24
|
- Any critical data, examples, or references needed to continue
|
|
25
|
+
- Preserve concise verbatim excerpts of the latest user request and active constraints in their original language, and write the summary in the user's primary language.
|
|
25
26
|
|
|
26
27
|
Be concise, structured, and focused on helping the next LLM seamlessly continue the work."""
|
|
27
28
|
|
|
@@ -31,15 +32,10 @@ SUMMARY_PREFIX = (
|
|
|
31
32
|
"that were used by that language model. Use this to build on the work that "
|
|
32
33
|
"has already been done and avoid duplicating work. Here is the summary "
|
|
33
34
|
"produced by the other language model, use the information in this summary "
|
|
34
|
-
"to assist with your own analysis
|
|
35
|
+
"to assist with your own analysis. Continue the current task directly; do "
|
|
36
|
+
"not merely repeat or acknowledge the handoff:"
|
|
35
37
|
)
|
|
36
38
|
|
|
37
|
-
COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000
|
|
38
|
-
_APPROX_CHARS_PER_TOKEN = 4
|
|
39
|
-
_SUBAGENT_NOTIFICATION_PREFIX = "<subagent_notification>\n"
|
|
40
|
-
_EXEC_COMMAND_COMPLETED_PREFIX = "<exec_command_completed>\n"
|
|
41
|
-
|
|
42
|
-
|
|
43
39
|
@dataclass(frozen=True)
|
|
44
40
|
class CompactResult:
|
|
45
41
|
history: 'typing.Tuple[ConversationItem, ...]'
|
|
@@ -67,8 +63,7 @@ def compact(
|
|
|
67
63
|
history: 'typing.Sequence[ConversationItem]',
|
|
68
64
|
) -> 'typing.Tuple[ConversationItem, ...]':
|
|
69
65
|
summary_text = _build_summary_message(_last_assistant_message(history))
|
|
70
|
-
|
|
71
|
-
return build_compacted_history(user_messages, summary_text)
|
|
66
|
+
return build_compacted_history(summary_text)
|
|
72
67
|
|
|
73
68
|
|
|
74
69
|
async def compact_agent(
|
|
@@ -159,53 +154,10 @@ def prune_oldest_tool_response(
|
|
|
159
154
|
)
|
|
160
155
|
|
|
161
156
|
|
|
162
|
-
def collect_user_messages(
|
|
163
|
-
history: 'typing.Sequence[ConversationItem]',
|
|
164
|
-
) -> 'typing.Tuple[str, ...]':
|
|
165
|
-
compact_prompt = _normalize_for_compare(DEFAULT_COMPACT_PROMPT)
|
|
166
|
-
collected: 'typing.List[str]' = []
|
|
167
|
-
for item in history:
|
|
168
|
-
if not isinstance(item, UserMessage):
|
|
169
|
-
continue
|
|
170
|
-
if is_summary_message(item.text):
|
|
171
|
-
continue
|
|
172
|
-
if _normalize_for_compare(item.text) == compact_prompt:
|
|
173
|
-
continue
|
|
174
|
-
if _is_synthetic_user_message(item.text):
|
|
175
|
-
continue
|
|
176
|
-
collected.append(item.text)
|
|
177
|
-
return tuple(collected)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def is_summary_message(message: 'str') -> 'bool':
|
|
181
|
-
return message.startswith(f"{SUMMARY_PREFIX}\n")
|
|
182
|
-
|
|
183
|
-
|
|
184
157
|
def build_compacted_history(
|
|
185
|
-
user_messages: 'typing.Sequence[str]',
|
|
186
158
|
summary_text: 'str',
|
|
187
|
-
max_tokens: 'int' = COMPACT_USER_MESSAGE_MAX_TOKENS,
|
|
188
159
|
) -> 'typing.Tuple[ConversationItem, ...]':
|
|
189
|
-
|
|
190
|
-
if max_tokens > 0:
|
|
191
|
-
remaining = max_tokens
|
|
192
|
-
for message in reversed(tuple(user_messages)):
|
|
193
|
-
if remaining <= 0:
|
|
194
|
-
break
|
|
195
|
-
tokens = _approx_token_count(message)
|
|
196
|
-
if tokens <= remaining:
|
|
197
|
-
selected_messages.append(message)
|
|
198
|
-
remaining -= tokens
|
|
199
|
-
continue
|
|
200
|
-
selected_messages.append(_truncate_text_to_tokens(message, remaining))
|
|
201
|
-
break
|
|
202
|
-
selected_messages.reverse()
|
|
203
|
-
|
|
204
|
-
compacted: 'typing.List[ConversationItem]' = [
|
|
205
|
-
UserMessage(text=message) for message in selected_messages
|
|
206
|
-
]
|
|
207
|
-
compacted.append(UserMessage(text=summary_text or _build_summary_message(None)))
|
|
208
|
-
return tuple(compacted)
|
|
160
|
+
return (UserMessage(text=summary_text or _build_summary_message(None)),)
|
|
209
161
|
|
|
210
162
|
|
|
211
163
|
def _last_assistant_message(
|
|
@@ -222,46 +174,12 @@ def _build_summary_message(summary_text: 'typing.Union[str, None]') -> 'str':
|
|
|
222
174
|
return f"{SUMMARY_PREFIX}\n{normalized}"
|
|
223
175
|
|
|
224
176
|
|
|
225
|
-
def _approx_token_count(text: 'str') -> 'int':
|
|
226
|
-
if not text:
|
|
227
|
-
return 0
|
|
228
|
-
return max(1, (len(text) + _APPROX_CHARS_PER_TOKEN - 1) // _APPROX_CHARS_PER_TOKEN)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def _truncate_text_to_tokens(text: 'str', max_tokens: 'int') -> 'str':
|
|
232
|
-
if max_tokens <= 0:
|
|
233
|
-
return ""
|
|
234
|
-
max_chars = max(max_tokens, 1) * _APPROX_CHARS_PER_TOKEN
|
|
235
|
-
if len(text) <= max_chars:
|
|
236
|
-
return text
|
|
237
|
-
|
|
238
|
-
removed_tokens = _approx_token_count(text[max_chars:])
|
|
239
|
-
suffix = f"\n...[{removed_tokens} tokens truncated]..."
|
|
240
|
-
available = max_chars - len(suffix)
|
|
241
|
-
if available <= 0:
|
|
242
|
-
return suffix.lstrip()
|
|
243
|
-
return text[:available].rstrip() + suffix
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def _normalize_for_compare(text: 'str') -> 'str':
|
|
247
|
-
return "\n".join(line.rstrip() for line in text.strip().splitlines()).strip()
|
|
248
|
-
|
|
249
|
-
|
|
250
177
|
def _pluralize(noun: 'str', count: 'int') -> 'str':
|
|
251
178
|
if count == 1:
|
|
252
179
|
return noun
|
|
253
180
|
return f"{noun}s"
|
|
254
181
|
|
|
255
182
|
|
|
256
|
-
def _is_synthetic_user_message(text: 'str') -> 'bool':
|
|
257
|
-
return text.startswith(
|
|
258
|
-
(
|
|
259
|
-
_SUBAGENT_NOTIFICATION_PREFIX,
|
|
260
|
-
_EXEC_COMMAND_COMPLETED_PREFIX,
|
|
261
|
-
)
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
|
|
265
183
|
def _is_context_length_error(message: 'str') -> 'bool':
|
|
266
184
|
lower = message.lower()
|
|
267
185
|
return (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: python-codex
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: A minimal Python extraction of Codex's main agent loop
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.6.2
|
|
@@ -23,8 +23,17 @@ Description-Content-Type: text/markdown
|
|
|
23
23
|
|
|
24
24
|
English README. Chinese version: `README_ZH.md`
|
|
25
25
|
|
|
26
|
-
PyPI
|
|
27
|
-
|
|
26
|
+
PyPI distributions:
|
|
27
|
+
|
|
28
|
+
- Primary package: `python-codex`
|
|
29
|
+
- Workspace install alias: `pycodex-ws`
|
|
30
|
+
|
|
31
|
+
The import path remains `pycodex`; the CLI commands are `pycodex` and
|
|
32
|
+
`pycodex-ws`.
|
|
33
|
+
|
|
34
|
+
`pycodex-ws` is a thin metapackage that depends on the exact matching
|
|
35
|
+
`python-codex` version. The implementation and console script remain owned by
|
|
36
|
+
`python-codex`, so the two distributions never install duplicate modules.
|
|
28
37
|
|
|
29
38
|
This repository extracts the core Codex agent loop from upstream Codex
|
|
30
39
|
(`https://github.com/openai/codex`) into a deliberately small Python version,
|
|
@@ -44,6 +53,13 @@ Relevant Rust reference points:
|
|
|
44
53
|
|
|
45
54
|
## Quick Start
|
|
46
55
|
|
|
56
|
+
Install the full package or the workspace-oriented alias:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install python-codex
|
|
60
|
+
pip install pycodex-ws
|
|
61
|
+
```
|
|
62
|
+
|
|
47
63
|
Install dependencies first:
|
|
48
64
|
|
|
49
65
|
```bash
|
|
@@ -179,7 +195,8 @@ Current behavior:
|
|
|
179
195
|
- interactive mode shows a compact event stream for user-visible phases such as
|
|
180
196
|
tool execution and model follow-up after tool results
|
|
181
197
|
- assistant text is printed from streaming deltas directly
|
|
182
|
-
- interactive mode supports `/history`, `/title`, `/model`, `/resume`,
|
|
198
|
+
- interactive mode supports `/history`, `/title`, `/model`, `/resume`, `/compact`,
|
|
199
|
+
and `/fork`
|
|
183
200
|
- `/model <name>` switches the model used by later turns in the current
|
|
184
201
|
interactive session; `/model` shows the current model and available choices
|
|
185
202
|
- `/resume` with no argument lists the currently resumable sessions by their
|
|
@@ -189,9 +206,15 @@ Current behavior:
|
|
|
189
206
|
- `/compact` synthesizes a local handoff summary, replaces the in-memory
|
|
190
207
|
conversation history with the compacted view, and appends a compacted-history
|
|
191
208
|
entry to the rollout so later `/resume` sees the same state
|
|
209
|
+
- `/fork` generates a new model session id while preserving the current history,
|
|
210
|
+
rollout, and workspace tab
|
|
192
211
|
- `model_auto_compact_token_limit = <tokens>` in `config.toml` enables the same
|
|
193
212
|
compaction path automatically when the latest reported usage reaches that
|
|
194
213
|
threshold before a follow-up sampling request or the next user turn
|
|
214
|
+
- `service_tier = "fast"` enables Fast mode for models whose vendored metadata
|
|
215
|
+
advertises the `priority` service tier; pycodex follows upstream Codex by
|
|
216
|
+
sending `service_tier = "priority"` on the Responses request, while
|
|
217
|
+
`service_tier = "default"` or unsupported tiers are omitted
|
|
195
218
|
- if a model request fails with `context_length_exceeded`, pycodex now treats
|
|
196
219
|
the provider-reported requested token count as a failed-request usage sample,
|
|
197
220
|
triggers the same compact path immediately, and retries the request once; if
|
|
@@ -219,8 +242,13 @@ Current behavior:
|
|
|
219
242
|
`delete(name)` controls; omitted names become `workspace-1`, `workspace-2`,
|
|
220
243
|
etc. If `board` is omitted when adding a workspace, pycodex assigns a random
|
|
221
244
|
writable `/tmp/pcws-*.html` board path. Add/delete actions and later
|
|
222
|
-
session-state saves refresh the JSON file.
|
|
223
|
-
|
|
245
|
+
session-state saves refresh the JSON file. Board HTML can reference local
|
|
246
|
+
images beside the board (including nested paths) with relative URLs; only
|
|
247
|
+
`image/*` files contained by the board directory are served.
|
|
248
|
+
Assistant Markdown supports KaTeX formulas with `$...$`, `$$...$$`,
|
|
249
|
+
`\(...\)`, and `\[...\]` delimiters.
|
|
250
|
+
`--password <value>` enables a password-only login page for workspace pages,
|
|
251
|
+
APIs, and websocket connections.
|
|
224
252
|
- steer is enabled by default in interactive mode: normal input goes into the
|
|
225
253
|
runtime steer path, the current request stops at the next safe boundary, and
|
|
226
254
|
later steer text is appended to the next model request's `input` in order;
|
|
@@ -3,12 +3,13 @@ pycodex/agent.py,sha256=HKzWVFRag_Le8LVR1qqfdfVzk23bRsXkykZV8Zq2hLE,21659
|
|
|
3
3
|
pycodex/cli.py,sha256=1wTlt3ccgQqczrWBV3IjUVE0_L2QUlfPZEf6GB-tGZY,20983
|
|
4
4
|
pycodex/collaboration.py,sha256=yQ6pBD-R3ZWR4_FAYQFoS7KF0m4LLD42otXIbPqw2ys,641
|
|
5
5
|
pycodex/compat.py,sha256=l35JE0vGAOCn9NWbWbqwGURGk83HXddXQ5wJIfcG41o,3254
|
|
6
|
-
pycodex/context.py,sha256=
|
|
6
|
+
pycodex/context.py,sha256=NRCJpYpEm76cAK1akid0BvQ-Z0mAvw1ZNboVxjPtm6U,26219
|
|
7
7
|
pycodex/doctor.py,sha256=De3M4hRBJq8ZeqsUJgHz0vitqrH18YugrEnz7oHhTdQ,10572
|
|
8
8
|
pycodex/feishu_card.py,sha256=De6pM--3MfhgGo-WcWfhm-fop5UtzjwKrZ4gI2Lls3w,26198
|
|
9
9
|
pycodex/feishu_link.py,sha256=DDQCYXQXgo_2SpXPyWlK8KYcumNO1-sqFuQDsEMQpTg,16427
|
|
10
|
-
pycodex/interactive_session.py,sha256=
|
|
11
|
-
pycodex/model.py,sha256=
|
|
10
|
+
pycodex/interactive_session.py,sha256=WzOD06d43ckF7SPer12JKzUPIK42Djw4kEjFRdM6_Lw,16420
|
|
11
|
+
pycodex/model.py,sha256=2euikRqEuemeCv0gyyITCyuTdEOTK9yT50HIzd75Vtg,41808
|
|
12
|
+
pycodex/model_metadata.py,sha256=7fHntKSUXhG5eASJi4yw4jU_IyzGbYBp-WE6af19ATA,877
|
|
12
13
|
pycodex/portable.py,sha256=gxl2E2h5uZJbasMEPPs-nyALFPIvX79T2ZYsu6vXZrg,15656
|
|
13
14
|
pycodex/portable_server.py,sha256=6I3pQkWj3e_SFlDXY2mGdCPns1w_3PSxByBV9wv5epI,7331
|
|
14
15
|
pycodex/protocol.py,sha256=4qiEcBQc3d3RZqsIKBjwORsHsQa78cqwvNljtnIuNbM,10795
|
|
@@ -17,7 +18,7 @@ pycodex/runtime_services.py,sha256=6PQMI4MM8F9imijaVKBIz_0ADtoKDcdYs6vQ-c4frtQ,1
|
|
|
17
18
|
pycodex/prompts/collaboration_default.md,sha256=MBTmPuMubeWfZgIeFVj49wwnwD4n_o3fVYAbgWKwu6Q,955
|
|
18
19
|
pycodex/prompts/collaboration_plan.md,sha256=IzjQAA5oHJz-3FmJdOjsJ4LHq6LW1tlEYMoy09n0HKk,8777
|
|
19
20
|
pycodex/prompts/default_base_instructions.md,sha256=D65mcj6bo4CDvVom-D9cbJRJVNquo0NghKt164_fRsg,20923
|
|
20
|
-
pycodex/prompts/models.json,sha256=
|
|
21
|
+
pycodex/prompts/models.json,sha256=Q3xyyqb6_c8_b3RN-0r0x2rpa-RUq5nxiCBtMdE8cZU,458204
|
|
21
22
|
pycodex/prompts/permissions/approval_policy/never.md,sha256=QceTG6wjkaJARjYr0HYV1aPnPcpGcrkRUW-smWRr6MQ,120
|
|
22
23
|
pycodex/prompts/permissions/approval_policy/on_failure.md,sha256=dfJjpXkpO6_ANdCKxbVJ8o4vyLxevrJWfKsGHTqtbkc,289
|
|
23
24
|
pycodex/prompts/permissions/approval_policy/on_request.md,sha256=hVQalzh0FAdkKzw5u-N4H7-LtC9ijVDlYsh3OKsZKzo,3661
|
|
@@ -46,7 +47,7 @@ pycodex/tools/send_input_tool.py,sha256=vRg-f7LI2Sr01j1LeCPKeXto_ROQZ7bcUC5GIjx9
|
|
|
46
47
|
pycodex/tools/shell_command_tool.py,sha256=wUw4lw8VLGIQ-7BIgyEsI0oqjj-Yr-MZn-_VDrnimAw,4202
|
|
47
48
|
pycodex/tools/shell_tool.py,sha256=1m-Tcbn3His4ggyK5ec8Mkg6ihopqTvBd9F6fJlm6m4,4054
|
|
48
49
|
pycodex/tools/spawn_agent_tool.py,sha256=7Me4Frjz7_fTpjMMFCvNmDHPOK2G1qTfXUr2Ugs61bU,6194
|
|
49
|
-
pycodex/tools/unified_exec_manager.py,sha256=
|
|
50
|
+
pycodex/tools/unified_exec_manager.py,sha256=YM0mKGygkprV51iAgt-A2YXJCG8Fyg3wuGGHktXuuQM,13957
|
|
50
51
|
pycodex/tools/update_plan_tool.py,sha256=UsChtCBqI1RnVnPQbByPmD2LMZXMoCwfe6NpGqP8qu0,3174
|
|
51
52
|
pycodex/tools/view_image_tool.py,sha256=2Xu5Vx7djVpz7-IV-LKrDKJjvVJc9AND-MFXp3CWplg,3892
|
|
52
53
|
pycodex/tools/wait_agent_tool.py,sha256=0Uj9-IrXe2dSvOtOMq-RAc0XzaidwFH5q7Mri3BXWyM,3135
|
|
@@ -55,7 +56,7 @@ pycodex/tools/web_search_tool.py,sha256=mhiK_G6VC6K6-01KctmIpsr-BURDF8L4ZJyY6IFG
|
|
|
55
56
|
pycodex/tools/write_stdin_tool.py,sha256=K--XMp-AyEdp8yQ9EseYHsK3QrGrK4AgzOyvUk4swmw,3608
|
|
56
57
|
pycodex/utils/__init__.py,sha256=p3jaERPxkimNDhmMIsm4glvKiazf3UMv1X-qoH5Zl3U,963
|
|
57
58
|
pycodex/utils/async_bridge.py,sha256=d21Pjim-nsQbSG5pJddd0WaQ03CzA3w3TINWDmmjWbg,1815
|
|
58
|
-
pycodex/utils/compactor.py,sha256=
|
|
59
|
+
pycodex/utils/compactor.py,sha256=REmxBha4UH7EODDhd9oKl0GsVsqujHCJ_2LUKR_R1qY,6524
|
|
59
60
|
pycodex/utils/debug.py,sha256=JeEB5JfzYfbdG0fXlrWFmXyR1ts86fKsI_97IqgF6R0,296
|
|
60
61
|
pycodex/utils/dotenv.py,sha256=rGKmurHjm7GdP4giyjHBPpSPv2Oi45qBqDB6HG3CnfA,1866
|
|
61
62
|
pycodex/utils/get_env.py,sha256=5fNhcNhujOakWV6AS66rGW3jEA68WGpuE4YVXJZFE6U,7427
|
|
@@ -79,12 +80,12 @@ responses_server/tools/custom_adapter.py,sha256=LxO7ldydvR-GWachDz8GKC0Q8KGGFoFP
|
|
|
79
80
|
responses_server/tools/web_search.py,sha256=pm4ZUiHUfxc0bGY1kEvt-BCzDrZIyP24xzPUcga2ul0,8908
|
|
80
81
|
workspace_server/__init__.py,sha256=PRM4ONODb6hxjBUHkBD4TprE_pkTCIlT9sfftK5ic6M,866
|
|
81
82
|
workspace_server/__main__.py,sha256=9SRp-Yw7ShGxc6DhSIXcDLKgGEdAVm3oBZ59rBOPjT0,62
|
|
82
|
-
workspace_server/app.py,sha256=
|
|
83
|
-
workspace_server/workspace.html,sha256=
|
|
83
|
+
workspace_server/app.py,sha256=20lTENFBlercCzFmtAR4fIeMvqIZr8Gq_J2VotmNPck,56858
|
|
84
|
+
workspace_server/workspace.html,sha256=UqUqOfQFra7KV8btI4BUTB38jkkhYZvzzdN5OWpQwdQ,48521
|
|
84
85
|
workspace_server/workspaces.html,sha256=GuYlFiOm1RJINrCKncClHt24D5i7XNABiP9C0xGxBA0,15679
|
|
85
86
|
workspace_server/workspaces.py,sha256=Z-zoj0xgkOOP2CwbQl3vAI6TwLIDUInJKyobs0Jt3ps,19962
|
|
86
|
-
python_codex-0.2.
|
|
87
|
-
python_codex-0.2.
|
|
88
|
-
python_codex-0.2.
|
|
89
|
-
python_codex-0.2.
|
|
90
|
-
python_codex-0.2.
|
|
87
|
+
python_codex-0.2.6.dist-info/METADATA,sha256=AjjNd0oWIKS2og9c2chID0_K4Jz2ICk2z6JfE_npUoM,19288
|
|
88
|
+
python_codex-0.2.6.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
|
|
89
|
+
python_codex-0.2.6.dist-info/entry_points.txt,sha256=vkV2UWCtEKvQNMJuPNjt8HyBKiwp83JyqBatrBNGDp8,80
|
|
90
|
+
python_codex-0.2.6.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
|
|
91
|
+
python_codex-0.2.6.dist-info/RECORD,,
|
workspace_server/app.py
CHANGED
|
@@ -2,6 +2,7 @@ import argparse
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import html
|
|
4
4
|
import json
|
|
5
|
+
import mimetypes
|
|
5
6
|
import os
|
|
6
7
|
import secrets
|
|
7
8
|
import threading
|
|
@@ -13,7 +14,13 @@ except ImportError: # pragma: no cover - Python 3.6 compatibility
|
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
16
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
16
|
-
from fastapi.responses import
|
|
17
|
+
from fastapi.responses import (
|
|
18
|
+
FileResponse,
|
|
19
|
+
HTMLResponse,
|
|
20
|
+
JSONResponse,
|
|
21
|
+
RedirectResponse,
|
|
22
|
+
Response,
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
from pycodex.cli import build_agent, build_cli_queue, build_model, configure_loguru
|
|
19
26
|
from pycodex.interactive_session import run_interactive_session
|
|
@@ -968,6 +975,14 @@ def create_multi_workspace_app(
|
|
|
968
975
|
return
|
|
969
976
|
await _websocket_session_handler(entry.manager, websocket)
|
|
970
977
|
|
|
978
|
+
@app.api_route(
|
|
979
|
+
"/w/{workspace_id}/{asset_path:path}",
|
|
980
|
+
methods=["GET", "HEAD"],
|
|
981
|
+
)
|
|
982
|
+
async def workspace_board_asset(workspace_id: str, asset_path: str) -> Response:
|
|
983
|
+
entry = _workspace_entry_or_404(registry, workspace_id)
|
|
984
|
+
return _board_asset_response(entry.definition.board_path, asset_path)
|
|
985
|
+
|
|
971
986
|
return app
|
|
972
987
|
|
|
973
988
|
|
|
@@ -1181,6 +1196,10 @@ def _install_workspace_routes(
|
|
|
1181
1196
|
return
|
|
1182
1197
|
await _websocket_session_handler(manager, websocket)
|
|
1183
1198
|
|
|
1199
|
+
@app.api_route("/{asset_path:path}", methods=["GET", "HEAD"])
|
|
1200
|
+
async def board_asset(asset_path: str) -> Response:
|
|
1201
|
+
return _board_asset_response(board_path, asset_path)
|
|
1202
|
+
|
|
1184
1203
|
|
|
1185
1204
|
def _board_status_response(board_path: "typing.Union[Path, None]") -> JSONResponse:
|
|
1186
1205
|
if board_path is None or not board_path.is_file():
|
|
@@ -1388,6 +1407,43 @@ def _board_response(board_path: "typing.Union[Path, None]") -> Response:
|
|
|
1388
1407
|
return _html_response(board_path.read_text(encoding="utf-8", errors="replace"))
|
|
1389
1408
|
|
|
1390
1409
|
|
|
1410
|
+
def _board_asset_response(
|
|
1411
|
+
board_path: "typing.Union[Path, None]",
|
|
1412
|
+
asset_path: str,
|
|
1413
|
+
) -> Response:
|
|
1414
|
+
media_type, unused_encoding = mimetypes.guess_type(str(asset_path or ""))
|
|
1415
|
+
del unused_encoding
|
|
1416
|
+
if board_path is None or not media_type or not media_type.startswith("image/"):
|
|
1417
|
+
raise HTTPException(status_code=404, detail="board image not found")
|
|
1418
|
+
|
|
1419
|
+
resolved_asset = None
|
|
1420
|
+
try:
|
|
1421
|
+
board_directory = board_path.parent.resolve()
|
|
1422
|
+
resolved_asset = (board_directory / asset_path).resolve()
|
|
1423
|
+
within_board_directory = (
|
|
1424
|
+
os.path.commonpath([str(board_directory), str(resolved_asset)])
|
|
1425
|
+
== str(board_directory)
|
|
1426
|
+
)
|
|
1427
|
+
except (OSError, RuntimeError, ValueError):
|
|
1428
|
+
within_board_directory = False
|
|
1429
|
+
|
|
1430
|
+
if (
|
|
1431
|
+
not within_board_directory
|
|
1432
|
+
or resolved_asset is None
|
|
1433
|
+
or not resolved_asset.is_file()
|
|
1434
|
+
):
|
|
1435
|
+
raise HTTPException(status_code=404, detail="board image not found")
|
|
1436
|
+
|
|
1437
|
+
return FileResponse(
|
|
1438
|
+
str(resolved_asset),
|
|
1439
|
+
media_type=media_type,
|
|
1440
|
+
headers={
|
|
1441
|
+
"Cache-Control": "no-cache",
|
|
1442
|
+
"X-Content-Type-Options": "nosniff",
|
|
1443
|
+
},
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
|
|
1391
1447
|
def _workspace_entry_or_404(
|
|
1392
1448
|
registry: 'WorkspaceRegistry',
|
|
1393
1449
|
workspace_id: str,
|