phern 0.1.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 (88) hide show
  1. ph_app/__init__.py +5 -0
  2. ph_app/__main__.py +15 -0
  3. ph_app/adapters/__init__.py +5 -0
  4. ph_app/adapters/_http.py +231 -0
  5. ph_app/adapters/_media.py +181 -0
  6. ph_app/adapters/anthropic.py +635 -0
  7. ph_app/adapters/google.py +785 -0
  8. ph_app/adapters/openai_compatible.py +884 -0
  9. ph_app/adapters/sse.py +58 -0
  10. ph_app/agents.py +713 -0
  11. ph_app/attach.py +160 -0
  12. ph_app/attachments.py +179 -0
  13. ph_app/cli.py +875 -0
  14. ph_app/console.py +181 -0
  15. ph_app/daemon/__init__.py +32 -0
  16. ph_app/daemon/cancelsafe.py +163 -0
  17. ph_app/daemon/cards.py +97 -0
  18. ph_app/daemon/client.py +295 -0
  19. ph_app/daemon/duplex.py +389 -0
  20. ph_app/daemon/follow.py +234 -0
  21. ph_app/daemon/framing.py +134 -0
  22. ph_app/daemon/frontend.py +284 -0
  23. ph_app/daemon/launch.py +184 -0
  24. ph_app/daemon/projections.py +283 -0
  25. ph_app/daemon/recovery.py +270 -0
  26. ph_app/daemon/server.py +1505 -0
  27. ph_app/daemon/supervisor.py +1541 -0
  28. ph_app/modes/__init__.py +20 -0
  29. ph_app/modes/json_mode.py +68 -0
  30. ph_app/modes/print_mode.py +67 -0
  31. ph_app/modes/rpc_mode.py +208 -0
  32. ph_app/modes/transcript_mode.py +94 -0
  33. ph_app/params.py +205 -0
  34. ph_app/payloads.py +717 -0
  35. ph_app/profiles/anthropic.yaml +9 -0
  36. ph_app/profiles/deepseek.yaml +16 -0
  37. ph_app/profiles/google.yaml +18 -0
  38. ph_app/profiles/llama.yaml +118 -0
  39. ph_app/profiles/rlm-stable.yaml +67 -0
  40. ph_app/profiles/tui.yaml +36 -0
  41. ph_app/profiles.py +414 -0
  42. ph_app/protocol.py +639 -0
  43. ph_app/py.typed +0 -0
  44. ph_app/runtime.py +154 -0
  45. ph_app/sessions.py +251 -0
  46. ph_app/shell.py +221 -0
  47. ph_app/trust.py +80 -0
  48. ph_app/tui/__init__.py +21 -0
  49. ph_app/tui/adapter.py +1203 -0
  50. ph_app/tui/app.py +882 -0
  51. ph_app/tui/autocomplete.py +163 -0
  52. ph_app/tui/commands.py +221 -0
  53. ph_app/tui/config.py +169 -0
  54. ph_app/tui/frontend.py +224 -0
  55. ph_app/tui/modals/__init__.py +1 -0
  56. ph_app/tui/modals/approval.py +188 -0
  57. ph_app/tui/modals/ask_user.py +95 -0
  58. ph_app/tui/modals/base.py +230 -0
  59. ph_app/tui/modals/login.py +81 -0
  60. ph_app/tui/modals/pickers.py +226 -0
  61. ph_app/tui/modals/trust.py +55 -0
  62. ph_app/tui/remote.py +771 -0
  63. ph_app/tui/screens.py +141 -0
  64. ph_app/tui/state.py +311 -0
  65. ph_app/tui/terminal.py +49 -0
  66. ph_app/tui/themes/__init__.py +230 -0
  67. ph_app/tui/themes/high-contrast.json +20 -0
  68. ph_app/tui/themes/ph-dark.json +20 -0
  69. ph_app/tui/themes/ph-light.json +20 -0
  70. ph_app/tui/trajectory.py +533 -0
  71. ph_app/tui/trajectory_app.py +164 -0
  72. ph_app/tui/trajectory_screen.py +271 -0
  73. ph_app/tui/widgets/__init__.py +1 -0
  74. ph_app/tui/widgets/prompt.py +232 -0
  75. ph_app/tui/widgets/selection.py +197 -0
  76. ph_app/tui/widgets/status.py +370 -0
  77. ph_app/tui/widgets/trajectory.py +301 -0
  78. ph_app/tui/widgets/transcript.py +563 -0
  79. ph_app/verbs.py +253 -0
  80. ph_app/web/__init__.py +26 -0
  81. ph_app/web/serve.py +450 -0
  82. ph_app/wire.py +246 -0
  83. ph_app/workspaces.py +186 -0
  84. phern-0.1.0.dist-info/METADATA +297 -0
  85. phern-0.1.0.dist-info/RECORD +88 -0
  86. phern-0.1.0.dist-info/WHEEL +4 -0
  87. phern-0.1.0.dist-info/entry_points.txt +8 -0
  88. phern-0.1.0.dist-info/licenses/LICENSE +21 -0
ph_app/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`ph_app` — the pH command line, and from Phase 2 the Textual TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
ph_app/__main__.py ADDED
@@ -0,0 +1,15 @@
1
+ """`python -m ph_app` — the CLI, reachable without a console script.
2
+
3
+ Exists for the daemon a UI starts on a person's behalf: `spawn_command` in
4
+ `ph_app.cli` says why it must be *this* interpreter rather than whatever `phern`
5
+ is on `PATH`.
6
+
7
+ @module ph_app.__main__
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .cli import main
13
+
14
+ if __name__ == "__main__":
15
+ main()
@@ -0,0 +1,5 @@
1
+ """Model adapters that speak real provider wires."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
@@ -0,0 +1,231 @@
1
+ """What every HTTP-streaming adapter shares: the client, the credential, the failure.
2
+
3
+ The three wires pH speaks differ in message shape, in how usage is reported, and
4
+ — since P7-03 — in how a file API is shaped. They do **not** differ in how a
5
+ request is sent, how a secret reaches a header, or what an HTTP status means for
6
+ retry, and when those were written twice the copies drifted (one overflow
7
+ heuristic matched `max_tokens` anywhere in a body, turning a bad-request 400 into
8
+ a compaction trigger). So they live here once.
9
+
10
+ One `httpx.AsyncClient` per adapter, not per request: creating a client inside
11
+ `stream()` pays a fresh TCP connect and TLS handshake on every model call and
12
+ never reaches keep-alive or HTTP/2 multiplexing. The adapter's row disposes the
13
+ client with its scope.
14
+
15
+ @module ph_app.adapters._http
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import AsyncIterator, Callable, Mapping
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+ from ph.cordis import Context
26
+ from ph.keys import CREDENTIALS
27
+ from ph.llm.adapter import LlmError
28
+ from ph.llm.types import CONTEXT_WINDOW_EXCEEDED, FILE_EXPIRED, LlmFailure
29
+
30
+ from .sse import iter_sse
31
+
32
+ __all__ = ["HttpClient", "failure_from_status", "resolve_secret"]
33
+
34
+ TIMEOUT = httpx.Timeout(600.0, connect=15.0)
35
+
36
+ _STATUS_CODES = {429: "RATE_LIMIT", 401: "AUTHENTICATION", 403: "AUTHENTICATION", 529: "OVERLOADED"}
37
+
38
+
39
+ def resolve_secret(ctx: Context, env_name: str, provider: str) -> str:
40
+ """Turn a credential *name* into its value — here, at the edge, and nowhere above (I-3).
41
+
42
+ The value goes into a local that goes out of scope with the request. Nothing
43
+ that travelled to get here held it.
44
+ """
45
+ credentials = ctx.get(CREDENTIALS)
46
+ if credentials is None:
47
+ raise LlmError("ctx.credentials is not mounted", "NO_CREDENTIALS")
48
+ secret = credentials.resolve(credentials.reference(env_name))
49
+ if secret is None:
50
+ raise LlmError(
51
+ f'{env_name} is not set, so provider "{provider}" cannot be called',
52
+ "MISSING_CREDENTIAL",
53
+ )
54
+ value: str = secret.reveal()
55
+ return value
56
+
57
+
58
+ def failure_from_status(
59
+ status: int,
60
+ body: str,
61
+ *,
62
+ is_overflow: Callable[[str], bool],
63
+ is_missing_file: Callable[[str], bool] | None = None,
64
+ ) -> LlmError:
65
+ """Classify an HTTP error into the codes the retry policy routes on.
66
+
67
+ The wire-specific judgements are callbacks because each provider phrases them
68
+ differently, and both are expensive to get wrong in either direction: a missed
69
+ overflow retries forever, a false one compacts a conversation that fit.
70
+
71
+ `is_missing_file` is the second of them (P7-03), here rather than in an
72
+ adapter because a body classified once must not be re-read into a different
73
+ code further up — and because both wires have a file API, so the next one
74
+ inherits this instead of writing its own parser. Whether the missing file was
75
+ *ours* is a separate question only the caller can answer, and it answers it
76
+ against the code rather than the prose.
77
+ """
78
+ code = _STATUS_CODES.get(status, "SERVER_ERROR" if status >= 500 else "REQUEST_FAILED")
79
+ if is_missing_file is not None and is_missing_file(body):
80
+ code = FILE_EXPIRED
81
+ if is_overflow(body):
82
+ code = CONTEXT_WINDOW_EXCEEDED
83
+ detail = body[:400] or f"HTTP {status}"
84
+ return LlmError(
85
+ f"provider returned {status}: {detail}",
86
+ code,
87
+ LlmFailure(message=detail, code=code, status=status),
88
+ )
89
+
90
+
91
+ class HttpClient:
92
+ """A lazily-created, long-lived `httpx.AsyncClient` with one streaming shape."""
93
+
94
+ def __init__(self) -> None:
95
+ self._client: httpx.AsyncClient | None = None
96
+
97
+ def _get(self) -> httpx.AsyncClient:
98
+ if self._client is None:
99
+ self._client = httpx.AsyncClient(timeout=TIMEOUT)
100
+ return self._client
101
+
102
+ async def aclose(self) -> None:
103
+ if self._client is not None:
104
+ await self._client.aclose()
105
+ self._client = None
106
+
107
+ async def post_multipart(
108
+ self,
109
+ url: str,
110
+ *,
111
+ headers: dict[str, str],
112
+ field: str,
113
+ filename: str,
114
+ content: bytes,
115
+ mime: str,
116
+ is_overflow: Callable[[str], bool],
117
+ data: dict[str, str] | None = None,
118
+ ) -> dict[str, Any]:
119
+ """POST one file as multipart form data and return the parsed reply.
120
+
121
+ Here rather than in an adapter for `stream_sse`'s reason: a file API is
122
+ one more thing both wires have, and the status→code classification is
123
+ the part that must not be written twice. `Content-Type` is left to
124
+ `httpx`, which has to compute the multipart boundary anyway.
125
+
126
+ `data` is the form fields that ride beside the file. Anthropic's Files API
127
+ takes none; OpenAI's requires `purpose`, and without a way to send it the
128
+ second wire's uploader would have had to build its own request and inherit
129
+ none of the classification above (P7-03).
130
+ """
131
+ sending = {name: value for name, value in headers.items() if name != "Content-Type"}
132
+ response = await self._get().post(
133
+ url, headers=sending, files={field: (filename, content, mime)}, data=data or {}
134
+ )
135
+ if response.status_code >= 400:
136
+ raise failure_from_status(
137
+ response.status_code,
138
+ response.text,
139
+ is_overflow=is_overflow,
140
+ )
141
+ parsed: dict[str, Any] = response.json()
142
+ return parsed
143
+
144
+ async def post_raw(
145
+ self,
146
+ url: str,
147
+ *,
148
+ headers: dict[str, str],
149
+ json: dict[str, Any] | None = None,
150
+ content: bytes | None = None,
151
+ is_overflow: Callable[[str], bool],
152
+ ) -> tuple[dict[str, Any], Mapping[str, str]]:
153
+ """POST a JSON body or raw bytes; return `(parsed body, response headers)`.
154
+
155
+ The **headers** are why this exists and why it is not `post_multipart`
156
+ (P7-03). Google's Files API is a two-step resumable upload whose first
157
+ step answers with an empty body and the destination in
158
+ `X-Goog-Upload-URL`, and whose second step is the file's bytes with no
159
+ form encoding around them at all. Neither shape fits a multipart helper,
160
+ and both want the same status→code classification, which is the whole
161
+ reason this module exists.
162
+
163
+ A body that is not JSON comes back as `{}` rather than raising: the first
164
+ step of that upload legitimately returns nothing, and a helper that
165
+ insisted on JSON would make the caller catch a decode error to discover
166
+ success.
167
+ """
168
+ response = await self._get().post(url, headers=headers, json=json, content=content)
169
+ if response.status_code >= 400:
170
+ raise failure_from_status(response.status_code, response.text, is_overflow=is_overflow)
171
+ try:
172
+ parsed: dict[str, Any] = response.json()
173
+ except ValueError:
174
+ parsed = {}
175
+ return parsed, response.headers
176
+
177
+ async def get_json(
178
+ self,
179
+ url: str,
180
+ *,
181
+ headers: dict[str, str],
182
+ is_overflow: Callable[[str], bool],
183
+ timeout: float | None = None,
184
+ ) -> dict[str, Any]:
185
+ """GET and parse — the poll half of an upload that finishes asynchronously.
186
+
187
+ `timeout` overrides `TIMEOUT` for one call, and exists for the caller
188
+ that is not waiting on a model: a *mount-time* probe inherits a ten
189
+ minute read budget otherwise, so a host that accepts the socket and then
190
+ stalls holds up plugin mount — and with it a TUI start and `phern doctor`.
191
+ A request whose answer nobody is blocked on keeps the generous default.
192
+ """
193
+ response = await self._get().get(
194
+ url,
195
+ headers=headers,
196
+ # httpx's own sentinel rather than a splat: "say nothing and take the
197
+ # client's" is a value here, and spreading a conditional dict past a
198
+ # keyword-typed signature is a hole the checker cannot see through.
199
+ timeout=httpx.Timeout(timeout) if timeout is not None else httpx.USE_CLIENT_DEFAULT,
200
+ )
201
+ if response.status_code >= 400:
202
+ raise failure_from_status(response.status_code, response.text, is_overflow=is_overflow)
203
+ parsed: dict[str, Any] = response.json()
204
+ return parsed
205
+
206
+ async def stream_sse(
207
+ self,
208
+ url: str,
209
+ *,
210
+ headers: dict[str, str],
211
+ json: dict[str, Any],
212
+ is_overflow: Callable[[str], bool],
213
+ is_missing_file: Callable[[str], bool] | None = None,
214
+ ) -> AsyncIterator[tuple[str, dict[str, Any]]]:
215
+ """POST and yield `(event, payload)` for every JSON SSE payload.
216
+
217
+ A non-2xx response is raised as a classified `LlmError` before any
218
+ payload is yielded, so a consumer never sees a half-stream.
219
+ """
220
+ async with self._get().stream("POST", url, headers=headers, json=json) as response:
221
+ if response.status_code >= 400:
222
+ body = (await response.aread()).decode("utf-8", errors="replace")
223
+ raise failure_from_status(
224
+ response.status_code,
225
+ body,
226
+ is_overflow=is_overflow,
227
+ is_missing_file=is_missing_file,
228
+ )
229
+ async for event, payload in iter_sse(response):
230
+ if isinstance(payload, dict):
231
+ yield event, payload
@@ -0,0 +1,181 @@
1
+ """Loading the bytes for media that is going out (P7-01).
2
+
3
+ The sibling of `_http.py`, and all that is left here once `ph.llm.media` answers
4
+ the *policy* question above every adapter: by the time a request reaches one, any
5
+ `MediaBlock` still on it is one this route said it accepts, so there is nothing
6
+ left to decide — only bytes to fetch and a wire shape to build, and the shape is
7
+ the adapter's own.
8
+
9
+ Also the *upload* half of that, once a second wire grew one (P7-03). `_http`'s
10
+ own docstring is the argument: the two adapters differ in message shape and in
11
+ how usage is reported, and they do **not** differ in what a dead file handle
12
+ means — so `forget_named_handle` lives here rather than being the third copy of
13
+ twelve lines whose bug would be invisible.
14
+
15
+ What is *not* here is the route projection (`MediaRoute`, `resolved`), which the
16
+ three adapters also share: its content is `ResolvedModel`'s field list, so it
17
+ belongs beside that dataclass in `ph.llm` rather than in an application
18
+ package — everything in this module needs `ctx.uploads`, the attachment store or
19
+ `FILE_EXPIRED` semantics to mean anything, and that one needed none of them.
20
+
21
+ @module ph_app.adapters._media
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from collections.abc import Collection, Sequence
28
+
29
+ from ph.cordis import Context
30
+ from ph.keys import UPLOADS
31
+ from ph.llm.adapter import LlmError
32
+ from ph.llm.media import media_pointer_text
33
+ from ph.llm.types import FILE_EXPIRED, AttachmentRef, Message, attachment_of
34
+ from ph.seams.attachments import AttachmentStore
35
+ from ph.seams.uploads import UploadRegistry
36
+
37
+ __all__ = ["forget_named_handle", "load_handles", "load_media", "media_pointer"]
38
+
39
+ log = logging.getLogger("ph_app.adapters.media")
40
+
41
+
42
+ def forget_named_handle(
43
+ ctx: Context, error: LlmError, referenced: Sequence[str], *, provider: str
44
+ ) -> LlmError:
45
+ """Drop the one handle this failure named, or leave the failure alone (P7-03).
46
+
47
+ `_http` has already decided the provider is talking about a missing file; what
48
+ only an adapter knows is whether it is a file *we* sent. A `not_found` naming
49
+ no id of ours is somebody else's 404 — a stale route, a gateway — and retrying
50
+ it would be the "unknown failure billed twice" the retry policy exists to
51
+ refuse, so it goes back to the code it came with.
52
+
53
+ **The named handle, not every handle.** A first draft invalidated all of them
54
+ on a match, which on a request carrying twenty files threw away nineteen live
55
+ uploads to re-fetch one dead one.
56
+
57
+ Shared rather than copied because the *classification* is already shared:
58
+ `failure_from_status` takes each wire's phrases and answers one code, and this
59
+ is what a caller does with that code. A second adapter writing its own would
60
+ be free to disagree about which half of the check is load-bearing, which is
61
+ the half that keeps `FILE_EXPIRED` out of an infinite retry.
62
+ """
63
+ uploads = ctx.get(UPLOADS)
64
+ message = str(error.failure.message)
65
+ named = [handle for handle in referenced if handle in message]
66
+ if uploads is None or not named:
67
+ return (
68
+ LlmError(
69
+ message,
70
+ "REQUEST_FAILED",
71
+ error.failure.model_copy(update={"code": "REQUEST_FAILED"}),
72
+ )
73
+ if error.code == FILE_EXPIRED
74
+ else error
75
+ )
76
+ for handle in named:
77
+ uploads.invalidate_handle(provider, handle)
78
+ return error
79
+
80
+
81
+ def media_pointer(attachment: AttachmentRef) -> dict[str, str]:
82
+ """The text block that stands in for media that could not be loaded.
83
+
84
+ Reached only on a race — `media-degrade` already checked the blob was there,
85
+ so arriving here means it went away between that check and this read. The
86
+ wording is `ph.llm.media`'s, so the model reads one sentence for one
87
+ situation however it was arrived at.
88
+ """
89
+ return {"type": "text", "text": media_pointer_text(attachment)}
90
+
91
+
92
+ async def load_handles(
93
+ uploads: UploadRegistry | None,
94
+ messages: Sequence[Message],
95
+ *,
96
+ provider: str,
97
+ mimes: frozenset[str],
98
+ session_id: str | None = None,
99
+ ) -> dict[str, str]:
100
+ """Provider file ids for the attachments this route references rather than inlines.
101
+
102
+ Keyed by attachment id like `load_media`, and consulted first by the
103
+ renderer: an id present here is sent as a reference, absent as bytes. The
104
+ two maps rather than one union type because the fallback has to be silent and
105
+ total — an upload that fails for any reason leaves the id out and the
106
+ attachment goes inline, which is what every route did before this row.
107
+
108
+ `mimes` is the route's own list rather than "everything large": which formats
109
+ are worth a round trip is a fact about a provider's file API, and video is
110
+ the case where it is not a choice.
111
+ """
112
+ handles: dict[str, str] = {}
113
+ if uploads is None or not mimes:
114
+ return handles
115
+ for message in messages:
116
+ for block in message.content:
117
+ attachment = attachment_of(block)
118
+ if attachment is None or attachment.attachment_id in handles:
119
+ continue
120
+ if attachment.mime not in mimes:
121
+ continue
122
+ try:
123
+ handle = await uploads.handle_for(
124
+ attachment, provider=provider, session_id=session_id
125
+ )
126
+ except Exception:
127
+ # Deliberately broad and deliberately not fatal: an upload is an
128
+ # optimisation, and a route that can take the bytes inline must
129
+ # not lose a turn because a file API was down.
130
+ log.warning(
131
+ "ph_app.adapters: could not upload %s to %s; sending it inline",
132
+ attachment.name or attachment.attachment_id,
133
+ provider,
134
+ exc_info=True,
135
+ )
136
+ continue
137
+ if handle is not None:
138
+ handles[attachment.attachment_id] = handle.handle
139
+ return handles
140
+
141
+
142
+ async def load_media(
143
+ store: AttachmentStore | None,
144
+ messages: Sequence[Message],
145
+ *,
146
+ skip: Collection[str] = (),
147
+ ) -> dict[str, str]:
148
+ """Base64 for every attachment still on the request, keyed by id.
149
+
150
+ Absent from the map means the read failed after `media-degrade` had approved
151
+ it, which is a race rather than a policy outcome — the caller renders a
152
+ pointer either way, so one branch covers both.
153
+
154
+ Through `AttachmentStore.load_b64`, which encodes once per process: a media
155
+ block lives in derived history for the session's life, so re-encoding per
156
+ request is a cost paid on every step.
157
+
158
+ **`skip` is what makes an upload actually cheaper** (P7-03). Without it a
159
+ referenced file was still read and base64-encoded here and then discarded by
160
+ the renderer — so the wire payload shrank and nothing else did, while a
161
+ 5.5 MB string sat in the store's encode cache for the life of the process.
162
+ Uploading is supposed to remove that work, not move it.
163
+ """
164
+ loaded: dict[str, str] = {}
165
+ if store is None:
166
+ return loaded
167
+ for message in messages:
168
+ for block in message.content:
169
+ attachment = attachment_of(block)
170
+ if attachment is None or attachment.attachment_id in loaded:
171
+ continue
172
+ if attachment.attachment_id in skip:
173
+ continue
174
+ try:
175
+ loaded[attachment.attachment_id] = await store.load_b64(attachment)
176
+ except OSError:
177
+ log.warning(
178
+ "ph_app.adapters: could not read %s; sending a pointer",
179
+ attachment.name or attachment.attachment_id,
180
+ )
181
+ return loaded