ph-core
Everything a pH profile is made of: the plugin framework, the append-only log, the ReAct loop, the capability seams — and the seventy-four rows that provide them.
There is no privileged core to patch (invariant I1). What this distribution ships is the vocabulary a profile is written in — a context that holds services, an event bus, scopes that unwind what they created — plus one row per thing pH can do, each addressable by id from a YAML document. The agent loop is a row. The session store is a row. The filesystem is a row. So is the tool registry that decides whether a tool call is even allowed to happen.
phern --profile headless -p "hello" # ph-base + the fake adapter
phern config --profile base # every knob every row accepts
phern doctor --profile base # what actually activated
ph-core is a dependency of every other package in this workspace and depends
on none of them.
What is in here
| module | what it owns |
|---|---|
ph.cordis |
the plugin meta-framework subset (D1): Context, service keys, the four event dispatch modes, scopes whose disposal unwinds every registration (I2), and the YAML profile loader |
ph.session |
the append-only event log, the derived model surface (derive_messages), the human transcript(), and the folds over both |
ph.llm |
the provider-neutral vocabulary, the stream assembler, the ctx.llm seam, plus the fake and replay adapters |
ph.agent / ph.agent_loop |
the agent handle and its inbox; the ReAct driver, mounted as a row like anything else |
ph.tools |
the registry, the governed pipeline (tools/pre-execute, tools/post-execute, guards), the batch scheduler, and Code Mode's transport and generated SDK |
ph.seams |
one module per capability seam: the Protocol, the local provider, and what it refuses |
ph.system_prompt |
prompt assembly — cached sections, post-cache context() snapshots, and AGENTS.md discovery |
ph.persistence |
JSONL and Turso backends, checkpoints, leases, lineage and crash repair |
ph.commands |
the slash commands ph-core itself owns |
ph.bundles |
base.yaml, headless.yaml, and the ph.bundles entry-point group other distributions register into |
ph.paths |
the three roots ($PH_HOME, $PH_CACHE, $PH_RUNTIME) and their resolution rules |
ph.testing |
builders, stubs and fixtures a test stands a profile up with. Nothing shipped imports it |
The two bundles it ships
ph-base (src/ph/bundles/base.yaml) is the shared core of every pH
profile: the log, the loop, the tool registry, every capability seam with its
local provider, the built-in tools, durability, resilience and the runtime
invariant rows. ph-headless adds one row — the scripted llm-fake
adapter — so a one-shot or a scenario test can script a conversation without
touching code.
Both are paths, not entry points, because ph-app can import them directly.
Every other bundle in this workspace is discovered through the ph.bundles
entry-point group, which is what lets ph-app compose the rlm profile without
depending on ph-rlm:
[project.entry-points."ph.bundles"]
rlm = "ph_rlm:BUNDLE"
The tools the model gets
Registered by rows, so a profile decides which of them exist at all.
| row | tools | note |
|---|---|---|
tool-fs |
read, write, edit, glob, grep |
thin shells over ctx.fs, so the policy gates and the workspace root apply to any second editing tool too |
tool-bash |
bash |
goes through ctx.shell, which is what sandbox-local confines |
tool-attach |
attach |
puts an image, audio file, video or PDF from the workspace in front of the model's own eyes. Registers nothing without an attachment store |
tool-ask-user |
ask_user |
ships disabled: a question with nobody to answer it spends a turn on nothing. tui.yaml arms it |
subagent-task |
task |
blocking delegation. Registers nothing until a ctx.subagents provider is mounted — a tool named in every prompt and refused on every call teaches the model a capability the deployment does not have |
skills-progressive |
skill |
the catalog goes in the prompt; a body only when the model asks for it by name (G9) |
The commands a person can type
These are commands, not tools: a person asks the harness directly, it costs
no model turn, and the log records command/run/command/done rather than the
model having decided something the user decided.
| command | row | what it does |
|---|---|---|
/sandbox |
sandbox-commands |
show what confined commands may reach, and change it without a restart |
/workspaces |
workspace-commands |
list, export, merge or remove the branches agents left behind in this session |
/revert |
workspace-revert |
restore this agent's workspace to a per-run checkpoint |
/autonomous |
autonomous |
work toward a goal until its gates pass or a budget stops it |
Adjusting it from a profile
Three layers, applied in this order — and a patch replaces the targeted row's whole config rather than merging into it, so a row's effective value is always one layer's and readable in one place:
- the shipped documents —
ph-base, then whatever the profile layers; - the host's rows,
rows:in$PH_HOME/daemon.yaml, and the daemon's own flags; - your named profile,
$PH_HOME/profiles/<name>.yaml—extends:a shipped profile androws:that differ from it; - drop-ins
/sandboxwrote before S4,$PH_HOME/profiles/<name>.d/*.yaml, read untilphern profiles foldfolds them —/sandboxnow records an override in the session's log instead; --patch, this run only, same grammar as a profile document.
Each row declares what it affects — environment, deployment or
presentation — and a layer a person writes sets one kind: layer 2 deployment,
layers 3–5 environment. A row set in the wrong layer is refused by name.
Presentation rows are set by no profile; a front end hides what it does not draw.
A patch entry is {id: …, config: {…}} to reconfigure, {id: …, disabled: false} to arm a row a bundle ships off, {id: …, remove: true} to drop one, or
{insert: [...]} to add one. A list in row config is replaced, not merged —
an overlay restating one field of a route must restate the whole route entry.
phern --patch '{id: fs, config: {root: /tmp/scratch}}' --profile tui --mode tui
phern --patch '{id: tool-ask-user, disabled: false}' --profile headless -p "..."
phern --dump-config --profile llama # the composition, before anything runs
phern config --row containment --profile tui # one row's knobs, defaults and what the profile set
The knobs most deployments touch
| row | config | default |
|---|---|---|
agent-loop |
maxParallelToolCalls |
10 |
tools |
mode: native | code |
native |
tools-code-mode |
maxDispatchesPerRun, maxSubagentSpawnsPerRun, maxParallelSubCalls |
256, 32, 10 |
fs-local |
root, ignore |
the session cwd |
sandbox-policy |
defaultMode: read-only | workspace-write |
read-only (tui.yaml sets workspace-write) |
sandbox-allow |
paths, network.{mode,hosts} |
an allowlist over the package indexes and documentation hosts in DEFAULT_HOSTS |
containment |
tier, childTier, strict |
unset — which is not advisory: unset means nobody chose, so a tier provider a profile layered is used |
workspace-lifecycle |
access, provision |
write |
jobs-local |
concurrency (per job kind) |
unset |
llm-retry |
maxAttempts, baseDelayMs, maxDelayMs, countAllRetries |
3, 500, 20000, false — only llm-retry's own retries spend its budget; true counts every row's, compaction's included |
session-telemetry |
enabled, path |
enabled: false in ph-base |
subagent-presets |
presets |
empty — a menu, never a grant: selecting a preset never widens what the parent itself holds |
skills-progressive |
paths |
empty on purpose — scanning a well-known directory would make "install a skill" mean "drop a file somewhere" (I7) |
autonomous |
maxContinuations, maxTurns, maxTokens, timeoutMs, tokenSources |
3, 12, 80000, 1800000, [own] — add compaction and children to charge summaries and subagents to maxTokens |
subprocess-local |
scrub, keep, maxOutputBytes |
8388608 |
tool-attach |
maxBytes |
33554432 |
An entry carrying both id: and name: is a row, not a patch — that is the
rule that decides which of the two a document line is. So swapping a provider
is a removal and an insertion, not a rename; consumers never learn which one
answered either way (I5):
# $PH_HOME/daemon.yaml — keep the log in Turso instead of JSONL (a host row)
rows:
- id: session-persistence
remove: true
- insert:
- id: session-persistence
name: session-persistence-turso
Giving the existing id a new name: in place looks like it should work and does
not: it appends a second row, and the mount then refuses with
service "session_persistence" is already provided.
Seams that ship with no provider, deliberately
ph-base mounts the definition of several seams and no backend, because these
have genuinely different answers per deployment and a harness that shipped one
would have made the choice for you:
code_runtime— nothing runs model-written code untilph-rlmmountscode-runtime-python(or a profile mountscode-runtime-stub).subagents— no child-agent provider, which is whysubagent-taskregisters no tool inph-base.compaction— the seam records and replaces; when and what to say areph-stabilize'scompaction-summarize. Every profileph-appoffers layers that bundle when it is installed, so a profile compacts;ph-basecomposed on its own still does not.uploads— mounted with no uploader; each adapter row registers its own, so a profile with no file API sends every byte inline.
A profile that layers nothing here simply never compacts, never delegates and never runs code. That is the plain harness, and it is a supported posture.
Limitations, and things that are deliberate
- No Textual, Rich, Typer,
ph_app, aiohttp, textual-serve or jinja2 imports —tests/test_layering.pyfails the build on any of them. A front end is a consumer of this package, never the other way round. - No real model wire ships here.
llm-fakeandllm-replayare for wiring work and tests; Anthropic, Google and the OpenAI-compatible route are rows inph-app, so a headless deployment that wants a real provider layers one of its profiles. sandbox-localis Linux (bwrap) and macOS (Seatbelt). It probes both claims at mount — a write outside the workspace must fail, and aCONNECTthrough the egress shim must reach the proxy and be refused — and on a host that cannot confine it says so inphern doctorand declines rather than pretending.permissions-fs-style path rules are not here — they areph-stabilize, and they bound seam-mediated access only. A model-authoredopen(path, "w")inside a code cell never fires an intent; what bounds that is the sandbox rung below the rules (E9, N1).- Nothing watches the filesystem.
AGENTS.mdis re-read as a post-cache snapshot each turn, which is what makes an edit take effect in the turn after it; nothing else polls. - Row order carries no load semantics. Activation is service-availability
driven, so a row waits for whatever it injects regardless of where it sits.
The grouping in
base.yamlis for readers.
Tests
tests/ — 75 modules, the largest suite in the workspace. Three worth knowing
about, because they enforce rules rather than behavior:
test_layering.py— the forbidden-import rule above;test_keys.py— everyctx.provide(...)in this package has a typed key inph.keys, and every key has a provider, held against each other in both directions;- the invariant rows (
agent-loop-invariant,session-invariant,tools-invariant,skills-invariant,scope-invariant) are checked at runtime, not only in CI — a harness that can only prove these in a test cannot prove them about your session, andphern doctorreports which hold.
Metadata
Release files for ph-core 0.10.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ph_core-0.10.0.tar.gz | 1.4 MB | Without provenance | Details for ph_core-0.10.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ph_core-0.10.0-py3-none-any.whl | Python 3 | none | any | Details for ph_core-0.10.0-py3-none-any.whl |
Total release size: 2.4 MB
Release files / ph_core-0.10.0.tar.gz
| Download URL | ph_core-0.10.0.tar.gz |
|---|---|
| Size | 1.4 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ded995cabda2ae49772c35ba71405b3b67a796c5fce725c2fdbe53302df466a8
|
|
BLAKE2b-256 checksum How to use checksums |
3c1a6f8862a82d628f4d8006a401208bbbbaf7664651365600a0fe90ac2644d9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / ph_core-0.10.0-py3-none-any.whl
| Download URL | ph_core-0.10.0-py3-none-any.whl |
|---|---|
| Size | 1.0 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ef7eaeb918d3b0d7666ccd26f889a01c51fe721f1dff4a242633047ed381cd53
|
|
BLAKE2b-256 checksum How to use checksums |
f7a39e30b0bae786cf41d0d158092a6e2d72fab8c792631684b4b526a1649c06
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for ph-core 0.10.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.